diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..095ea96b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,32 @@ +## Description + +_Describe the change simply. Provide a reason for the change._ + +_Include screenshots of any new or modified screens (or at least explain why they were omitted)_ + +This pull request is categorized as a: + +- [ ] New feature +- [ ] Bug fix +- [ ] Code refactor +- [ ] Documentation +- [ ] Other + +## Checklist + +- [ ] I’ve run `pytest` and made sure all unit tests pass before sumbitting the PR + +If you modified or added functionality/workflow, did you add new unit tests? + +- [ ] No, I’m a fool +- [ ] Yes +- [ ] N/A + +I have tested this PR on the following platforms/os: + +- [ ] Raspberry Pi OS [Manual Build](https://github.com/SeedSigner/seedsigner/blob/dev/docs/manual_installation.md) +- [ ] [SeedSigner OS](https://github.com/SeedSigner/seedsigner-os) on a Pi0/Pi0W board +- [ ] Other + + +Note: Keep your changes limited in scope; if you uncover other issues or improvements along the way, ideally submit those as a separate PR. The more complicated the PR the harder to review, test, and merge. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..a0649dc7 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,157 @@ +name: Build + +on: + pull_request: + # Build on changes to this workflow files in PRs to test proposed changes + paths: + - '.github/workflows/build.yml' + push: + branches: + - main + - dev + workflow_dispatch: + inputs: + os-ref: + description: The seedsigner-os ref (tag/branch/sha1) to use + default: main + required: true + +# Increment this number as part of a PR to trigger an image build for the PR +# trigger = 0 + +jobs: + build: + name: build + runs-on: ubuntu-latest + # Prevent resource consuming cron triggered runs in forks + if: (!github.event.repository.fork || github.event_name == 'workflow_dispatch') + strategy: + fail-fast: false + matrix: + target: [ "pi0", "pi2", "pi02w", "pi4" ] + steps: + - name: checkout seedsigner-os + uses: actions/checkout@v3 + 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 + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.os-ref || 'main' }} + submodules: true + path: "seedsigner-os" + # get full history + tags for "git describe" + fetch-depth: 0 + + - name: checkout source + uses: actions/checkout@v3 + with: + # ref defaults to repo default-branch=dev (cron) or SHA of event (workflow_dispatch) + path: "seedsigner-os/opt/rootfs-overlay/opt" + # get full history + tags for "git describe" + fetch-depth: 0 + + - name: Get and set meta data + run: | + # The builder_hash (seedsigner-os hash) for the cache action step key + echo "builder_hash=$(git -C seedsigner-os rev-parse --short HEAD)"| tee -a $GITHUB_ENV + + # Derive tag based versions, like 0.7.0-40-g0424967 (=$tag-$number-of-commits-since-tag-$short-sha1), + # or just e.g. 0.7.0, if we are exactly on a 0.7.0 tagged commit. + # --always to fall back to commit sha, if no tag present like in partial forks of the repo + os_version="$(git -C seedsigner-os describe --tags --always)" + source_version="$(git -C seedsigner-os/opt/rootfs-overlay/opt describe --tags --always)" + + # Combine seedsigner and seedsigner-os version into one version string and squash the versions, if + # they are identical: So os_version=0.7.0 + source_version=0.7.0 combine to just only "0.7.0", + # whereas os_version=0.6.0-61-g9fafebe + source_version=0.7.0-40-g0424967 combine to "os0.6.0-61-g9fafebe_sw0.7.0-40-g0424967" + if [ "${os_version}" = "${source_version}" ]; then + # seedsigner + seedsigner_os have the same tag + echo "img_version=${source_version}"| tee -a $GITHUB_ENV + else + echo "img_version=os${os_version}_sw${source_version}"| tee -a $GITHUB_ENV + fi + + - name: delete unnecessary files + run: | + cd seedsigner-os/opt/rootfs-overlay/opt + find . -mindepth 1 -maxdepth 1 ! -name src -exec rm -rf {} + + ls -la . + ls -la src + + - name: restore build cache + uses: actions/cache@v3 + # Caching reduces the build time to ~50% (currently: ~30 mins instead of ~1 hour, + # while consuming ~850 MB storage space). + with: + path: | + ~/.buildroot-ccache/ + seedsigner-os/buildroot_dl + key: build-cache-${{ matrix.target }}-${{ env.builder_hash }} + restore-keys: | + build-cache-${{ matrix.target }}- + + - name: build + run: | + cd seedsigner-os/opt + ./build.sh --${{ matrix.target }} --skip-repo --no-clean + + - name: list image (before rename) + run: | + ls -la seedsigner-os/images + + - name: rename image + run: | + cd seedsigner-os/images + mv seedsigner_os*.img seedsigner_os.${{ env.img_version }}.${{ matrix.target }}.img + + - name: print sha256sum + run: | + cd seedsigner-os/images + sha256sum *.img + + - name: list image (after rename) + run: | + ls -la seedsigner-os/images + + - name: upload images + uses: actions/upload-artifact@v3 + with: + name: seedsigner_os_images + path: "seedsigner-os/images/*.img" + if-no-files-found: error + # maximum 90 days retention + retention-days: 90 + + sha256sum: + name: calculate sha256sum + runs-on: ubuntu-latest + needs: build + steps: + - name: download images + uses: actions/download-artifact@v3 + with: + name: seedsigner_os_images + path: images + + - name: list images + run: | + ls -la images + + - name: get seedsigner latest commit hash + id: get-seedsigner-hash + run: | + git init + echo "source_hash=$(git rev-parse --short ${{ github.sha }})" >> $GITHUB_ENV + + - name: write sha256sum + run: | + cd images + sha256sum *.img > seedsigner_os.${{ env.source_hash }}.sha256 + + - name: upload checksums + uses: actions/upload-artifact@v3 + with: + name: seedsigner_os_images + path: "images/*.sha256" + if-no-files-found: error + # maximum 90 days retention + retention-days: 90 diff --git a/.github/workflows/telegram.yml b/.github/workflows/telegram.yml new file mode 100644 index 00000000..78b506d3 --- /dev/null +++ b/.github/workflows/telegram.yml @@ -0,0 +1,18 @@ +name: GitHub Notify on Telegram +on: + pull_request_target: + branches: + - dev + types: + - closed + +jobs: + if_merged: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Notify on Telegram + uses: EverythingSuckz/github-telegram-notify@main + with: + bot_token: '${{ secrets.BOT_TOKEN }}' + chat_id: '${{ secrets.CHAT_ID }}' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..294e20be --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: + - dev + - main + pull_request: + +concurrency: + # Concurrency group that uses the workflow name and PR number if available + # or commit SHA as a fallback. If a new build is triggered under that + # concurrency group while a previous build is running it will be canceled. + # Repeated pushes to a PR will cancel all previous builds, while multiple + # merges to main will not cancel. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + # 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 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + sudo apt-get install libzbar0 + python -m pip install --upgrade pip + pip install -r requirements.txt -r tests/requirements.txt + pip install . + - name: Test with pytest + run: | + mkdir artifacts + python -m pytest \ + --color=yes \ + --cov=seedsigner \ + --cov-append \ + --cov-branch \ + --cov-report term \ + --cov-report html \ + --cov-report html:./artifacts/cov_html \ + --cov-report xml \ + --durations 5 \ + -vv + - name: Generate screenshots + run: | + python -m pytest tests/screenshot_generator/generator.py + cp -r ./seedsigner-screenshots ./artifacts/ + - name: Archive CI Artifacts + uses: actions/upload-artifact@v3 + with: + name: ci-artifacts + path: artifacts/** + retention-days: 10 + # Upload also when tests fail. The workflow result (red/green) will + # be not effected by this. + if: always() diff --git a/.gitignore b/.gitignore index 6ae5b379..f2fbb160 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ src/seedsigner.egg-info/ .nova .vscode src/seedsigner/models/settings_definition.json +.idea +*.mo +.coverage +seedsigner-screenshots \ No newline at end of file diff --git a/README.md b/README.md index b4469cb4..8d4163e2 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,68 @@ # Build an offline, airgapped Bitcoin signing device for less than $50! -![Image of SeedSigners in Open Pill Enclosures](docs/img/Open_Pill_Star.JPG)![Image of SeedSigner in an Orange Pill enclosure](docs/img/Orange_Pill.JPG) +![Image of SeedSigners in Mini Pill Enclosures](docs/img/Mini_Pill_Main_Photo.jpg) --------------- * [Project Summary](#project-summary) * [Shopping List](#shopping-list) * [Software Installation](#software-installation) - * [Verifying Your Software](#verifying-your-software) + * [Verifying the Software](#verifying-the-software) * [Enclosure Designs](#enclosure-designs) * [SeedQR Printable Templates](#seedqr-printable-templates) -* [Manual Installation Instructions](#manual-installation-instructions) +* [Build from Source](#build-from-source) +* [Developer Local Build Instructions](#developer-local-build-instructions) --------------- # Project Summary -The goal of SeedSigner is to lower the cost and complexity of Bitcoin multi-signature wallet use. To accomplish this goal, SeedSigner offers anyone the opportunity to build a verifiably air-gapped, stateless Bitcoin signing device using inexpensive, publicly available hardware components (usually < $50). SeedSigner helps users save with Bitcoin by assisting with trustless private key generation and multi-signature wallet setup, and helps users transact with Bitcoin via a secure, air-gapped QR-exchange signing model. +[![CI](https://github.com/SeedSigner/seedsigner/actions/workflows/tests.yml/badge.svg)](https://github.com/SeedSigner/seedsigner/actions/workflows/tests.yml) +[![Build](https://github.com/SeedSigner/seedsigner/actions/workflows/build.yml/badge.svg)](https://github.com/SeedSigner/seedsigner/actions/workflows/build.yml) -Additional information about the project can be found at [seedsigner.com](https://seedsigner.com). +The goal of SeedSigner is to lower the cost and complexity of Bitcoin multi-signature wallet use. To accomplish this goal, SeedSigner offers anyone the opportunity to build a verifiably air-gapped, stateless Bitcoin signing device using inexpensive, publicly available hardware components (usually < $50). SeedSigner helps users save with Bitcoin by assisting with trustless private key generation and multisignature (aka "multisig") wallet setup, and helps users transact with Bitcoin via a secure, air-gapped QR-exchange signing model. + +Additional information about the project can be found at [SeedSigner.com](https://seedsigner.com). You can follow [@SeedSigner](https://twitter.com/SeedSigner) on Twitter for the latest project news and developments. If you have specific questions about the project, our [Telegram Group](https://t.me/joinchat/GHNuc_nhNQjLPWsS) is a great place to ask them. ### Feature Highlights: -* Calculate word 12/24 of a BIP39 seed phrase -* Create a 24-word BIP39 seed phrase with 99 dice rolls -* Create a 24-word BIP39 seed phrase by taking a digital photo -* Temporarily store up to 3 seed phrases while device is powered -* Guided interface to manually create a SeedQR for instant input [(demo video here)](https://youtu.be/c1-PqTNx1vc) -* BIP39 passphrase / word 25 support -* Native Segwit Multisig XPUB generation w/ QR display -* Scan and parse transaction data from animated QR codes +* Calculate the final word (aka checksum) of a 12- or 24-word BIP39 seed phrase +* Create a 24-word BIP39 seed phrase with 99 dice rolls or a 12-word with 50 rolls [(Verifying dice seed generation)](docs/dice_verification.md) +* Create a 12- or 24-word BIP39 seed phrase via image entropy from the onboard camera +* Temporarily stores seeds in memory while the device is powered; all memory is wiped when power is removed +* SD card removable after boot to ensure no secret data can be written to it +* Guided interface to manually transcribe a seed to the SeedQR format for instant seed loading [(demo video here)](https://youtu.be/c1-PqTNx1vc) +* BIP39 passphrase (aka "word 25") support +* Native Segwit Multisig XPUB generation +* PSBT-compliant; scan and parse transaction data from animated QR codes * Sign transactions & transfer XPUB data using animated QR codes [(demo video here)](https://youtu.be/LPqvdQ2gSzs) -* Live preview during photo-to-seed and QR scanning UX +* Live preview during image entropy seed generation and QR scanning UX * 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 * Responsive, event-driven user interface ### Considerations: * Built for compatibility with Specter Desktop, Sparrow, and BlueWallet Vaults * Device takes up to 60 seconds to boot before menu appears (be patient!) -* Always test your setup before transfering larger amounts of bitcoin (try testnet first!) +* Always test your setup before transferring larger amounts of bitcoin (try Testnet first!) * Taproot not quite yet supported * Slightly rotating the screen clockwise or counter-clockwise should resolve lighting/glare issues * If you think SeedSigner adds value to the Bitcoin ecosystem, please help us spread the word! (tweets, pics, videos, etc.) ### Planned Upcoming Improvements / Functionality: -* Single-sig and multi-sig change address verification -* Re-imagined, graphically-focused user interface * Multi-language support -* Customized Linux live-boot OS to allow MicroSD card removal +* Significantly faster boot time +* Reproducible builds +* Port to MicroPython to broaden the range of compatible hardware to include low-cost microcontrollers * Other optimizations based on user feedback! --------------- @@ -64,7 +71,7 @@ If you have specific questions about the project, our [Telegram Group](https://t To build a SeedSigner, you will need: -* Raspberry Pi Zero (preferably version 1.3 with no WiFi/Bluetooth capability, but any Raspberry Pi 2/3/4 or Zero model will work) +* Raspberry Pi Zero (preferably version 1.3 with no WiFi/Bluetooth capability, but any Raspberry Pi 2/3/4 or Zero model will work, Raspberry Pi 1 devices will require a hardware modification to the Waveshare LCD Hat, as per the [instructions here](./docs/legacy_hardware.md)) * Waveshare 1.3" 240x240 pxl LCD (correct pixel count is important, more info at https://www.waveshare.com/wiki/1.3inch_LCD_HAT) * Pi Zero-compatible camera (tested to work with the Aokin / AuviPal 5MP 1080p with OV5647 Sensor) @@ -76,53 +83,186 @@ Notes: --------------- # Software Installation -The quickest and easiest way to install the software is to download the most recent "seedsigner_X_X_X.zip" file in the [software releases](https://github.com/SeedSigner/seedsigner/releases) section of this repository. -After downloading the .zip file, extract the seedsigner .img file, and write it to a MicroSD card (at least 4GB in size or larger). Then install the MicroSD in the assembled hardware and off you go. If your goal is a more trustless installation, you can follow the [manual installation instructions](docs/manual_installation.md). +## A Special Note On Minimizing Trust +As is the nature of pre-packaged software downloads, downloading and using the prepared SeedSigner release images means implicitly placing trust in the people preparing those images; in our project the released images are prepared and signed by the eponymous creator of the project, SeedSigner "the person". That individual is additionally the only person in possession of the PGP keys that are used to sign the release images. -## Verifying Your Software -You can verify the data integrity and authenticity of the latest release with as little as three commands. This process assumes that you know [how to navigate on a terminal](https://terminalcheatsheet.com/guides/navigate-terminal) and have navigated to the folder where you have these four relevant files present: (This will most likely be your Downloads folder.) +Starting with v0.7.0, the images distributed via GitHub are reproducible. This means you and others can verify the released images are byte-for-byte the same when built from source. You can contribute to this project by building from source and sharing the hash of the final images. -* seedsigner_pubkey.gpg (from the main folder of this repo) -* seedsigner_0_4_6.img.zip (from the software release) -* seedsigner_0_4_6.img.zip.sha256 (from the software release) -* seedsigner_0_4_6.img.zip.sha256.sig (from the software release) +Instructions to build a SeedSigner OS image (using precisely the same process that is used to create the prepared release images) have been made available. We have put a lot of thought and work into making these instructions easy to understand and follow, even for less technical users. These instructions can be found [here](https://github.com/SeedSigner/seedsigner-os/blob/main/docs/building.md). -**Note:** The specific version number of the files in your folder might not match the above exactly, but their overall format and amount should be the same. +## Downloading the Software + + +Download the current Version (0.8.0) software image that is compatible with your Raspberry Pi Hardware. The Pi Zero 1.3 is the most common and recommended board. +| Board | Download Image Link/Name | +| --------------------- | --------------------------------- | +|**[Raspberry Pi Zero 1.3](https://www.raspberrypi.com/products/raspberry-pi-zero/)** |[`seedsigner_os.0.8.0.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.0/seedsigner_os.0.8.0.pi0.img) | +|[Raspberry Pi Zero W](https://www.raspberrypi.com/products/raspberry-pi-zero-w/) |[`seedsigner_os.0.8.0.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.0/seedsigner_os.0.8.0.pi0.img) | +|[Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) |[`seedsigner_os.0.8.0.pi02w.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.0/seedsigner_os.0.8.0.pi02w.img) | +|[Raspberry Pi 1 Model B/B+](https://www.raspberrypi.com/products/raspberry-pi-1-model-b-plus/) |[`seedsigner_os.0.8.0.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.0/seedsigner_os.0.8.0.pi0.img) | +|[Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) |[`seedsigner_os.0.8.0.pi2.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.0/seedsigner_os.0.8.0.pi2.img) | +|[Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) |[`seedsigner_os.0.8.0.pi02w.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.0/seedsigner_os.0.8.0.pi02w.img) | +|[Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) |[`seedsigner_os.0.8.0.pi4.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.0/seedsigner_os.0.8.0.pi4.img) | +|[Raspberry Pi 400](https://www.raspberrypi.com/products/raspberry-pi-400-unit/) |[`seedsigner_os.0.8.0.pi4.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.0/seedsigner_os.0.8.0.pi4.img) | + +Note: If you have physically removed the WiFi component from your board, you will still use the image file of the original(un-modified) hardware. (Our files are compiled/based on the *processor* architecture). Although it is better to spend a few minutes upfront to determine which specific Pi hardware/model you have, if you are still unsure which hardware you have, you can try using the pi0.img file. Making an incorrect choice here will not ruin your board, because this is software, not firmware. + +**also download** these 2 signature verification files to the same folder +[The Plaintext manifest file](https://github.com/SeedSigner/seedsigner/releases/download/0.8.0/seedsigner.0.8.0.sha256.txt) +[The Signature of the manifest file](https://github.com/SeedSigner/seedsigner/releases/download/0.8.0/seedsigner.0.8.0.sha256.txt.sig) + +Users familiar with older versions of the SeedSigner software might be surprised with how fast their software downloads now are, because since version 0.6.0 the software image files are now 100x smaller! Each image file is now under 42 Megabytes so your downloads and verifications will be very quick now (and might even seem *too* quick)! + +Once the files have all finished downloading, follow the steps below to verify the download before continuing on to write the software onto a MicroSD card. Next, insert the MicroSD into your assembled hardware and connect the USB power. Allow about 45 seconds for our logo to appear, and then you can begin using your SeedSigner! + +[Our previous software versions are available here](https://github.com/SeedSigner/seedsigner/releases). Choose a specific version and then expand the *Assets* sub-heading to display the .img file binary and also the 2 associated signature files. **Note:** The prior version files will have lower numbers than the scripts and examples provided in this document, but the naming format will be the same, so you can edit them as required for signature verification etc. + + +## Verifying that the downloaded files are authentic (optional but highly recommended!) + +You can quickly verify that the software you just downloaded is both authentic and unaltered, by following these instructions. +We assume you are running the commands from a computer where both [GPG](https://gnupg.org/download/index.html) and [shasum](https://command-not-found.com/shasum) are already installed, and that you also know [how to navigate on a terminal](https://terminalcheatsheet.com/guides/navigate-terminal). + +> You must run the following verification before opening or mounting the .img file. +> Some operating systems modify the file on mount causing verification to fail. + +### Step 1. Verify that the signature (.sig) file is genuine: + +Run GPG's *fetch-keys* command to import the SeedSigner projects public key from the popular online keyserver called *Keybase.io*, into your computers *keychain*. -This process also assumes you are running the commands from a system where both [GPG](https://gnupg.org/download/index.html) and [shasum](https://command-not-found.com/shasum) are installed and working. -First make sure that the public key is present in your keychain: ``` -gpg --import seedsigner_pubkey.gpg +gpg --fetch-keys https://keybase.io/seedsigner/pgp_keys.asc ``` -This command will import the public key, or return: +The result should confirm that 1 key was *either* imported or updated. *Ignore* any key ID's or email addresses shown. + +![SS - Fetchkeys-Keybase PubKey import with Fingerprint shown (New import or update of the key)v3-100pct](https://user-images.githubusercontent.com/91296549/221334414-adc3616c-462e-490e-8492-3dfee367d13a.jpg) + +Next, you will run the *verify* command on the signature (.sig) file. (*Verify* must be run from inside the same folder that you downloaded the files into earlier. The `*`'s in this command will auto-fill the version from your current folder, so it should be copied and pasted as-is.) ``` -key <...> not changed +gpg --verify seedsigner.0.7.*.sha256.txt.sig ``` -Now you can verify the authenticity of the small text file containing the release's SHA256 hash with the command: -``` -gpg --verify seedsigner_0_*_*.img.zip.sha256.sig -``` -**Note:** The `*`s in the command above allow the terminal to auto-populate the command with the version number you have in the folder you are in. It should be copied and pasted as is. +When the verify command completes successfully, it should display output like this: +
+![SS - Verify Command - GPG on Linux - Masked_v4-100pct](https://user-images.githubusercontent.com/91296549/221334135-8ad1f1af-26d2-429a-91ce-ad41703ed38c.jpg) +The result must display "**Good signature**". Ignore any email addresses - *only* matching Key fingerprints count here. Stop immediately if it displays "*Bad signature*"! +
-The reponse to this command should include the text: -``` -Good signature from "seedsigner " [unknown] -``` -The previous command validates that aforementioned small text file was signed using the private key that matches the published public key associated with the project (an early timestamped record of this public/private key's creation can be found in this [tweet](https://twitter.com/SeedSigner/status/1389617642286329856?s=20)). +On the *last* output line, look at your *rightmost* 16 characters (the 4 blocks of 4). +**Crucially, we must now check WHO that Primary key fingerprint /ID belongs to.** We will start by looking at Keybase.io to see if it is the *SeedSigner project* 's public key or not. + +
About the warning message: +

Since you are about to match the outputted fingerprint/ID against the proofs at Keybase.io/SeedSigner, and thereby confirm who the pubkey really belongs to-, you can safely ignore this warning message: -The last step is to make sure the .zip file that you've downloaded, and that contains the released software, is a perfect match to the software that was published by the holder of the private key in the last step. The command for this step is: ``` -shasum -a 256 -c seedsigner_0_*_*.img.zip.sha256 +> WARNING: This key is not certified with a trusted signature! +> There is no indication that the signature belongs to the owner. + ``` +

+
+
+ +
More about how the verify command works: +

+The verify command will attempt to decrypt the signature file (sha256.sig) by trying each public key already imported into your computer. If the public key we just imported (via fetch-keys), manages to: (a) successfully decrypt the .sig file , and (b), that result matches exactly to the clear-text equivalent (.sha256) of the .sig file, then its "a good signature"! + +Crucially, we must still manually check who *exactly* owns the Key ID which gave us that "Good signature". Thats what the warning message means- Who does the matching key really belong to? We will start by looking at keybase.io to see if it is "The SeedSigner project"'s public Key or not. +Note that it is the file hashes of .sig and .sha256 that *verify* compares, not their raw contents. + +

+
+
+ +Now to determine ***who*** the Public key ID belongs to: Goto [Keybase.io/SeedSigner](https://keybase.io/seedsigner) +
+![SS - Keybase Website PubKey visual matching1_Cropped-80pct](https://user-images.githubusercontent.com/91296549/215326193-97c84e35-5570-4e52-bf3f-e86d367c8908.jpg) + + + +**You must now *manually* compare: The 16 character fingerprint ID (as circled in red above) to, those *rightmost* 16 characters from your *verify* command.** + +**If they match exactly, then you have successfully confirmed that your .sig file is authentically from the SeedSigner Project!** +
+ +
Learn more about how keybase.io helps you check that someone (online) is who they say they are: +

+Keybase.io allows you to independently verify that the public key saved on Keybase.io, is both authentic and that it belongs to the organization it claims to represent. + Keybase has already checked the three pubkey file locations cryptographically when they were saved there. You can further verify the key publications if you would like: + + - *via Keybase*: By clicking on any of the three blue badges to see that the "proof" was published at that location. (The blue badge marked as tweet, is in the most human-readable form and it is also a bi-directional link on Twitter) +or, + - *without keybase (out-of-band)*: By using these 3 links directly: [Twitter](https://twitter.com/SeedSigner/status/1530555252373704707), [Github](https://gist.github.com/SeedSigner/5936fa1219b07e28a3672385b605b5d2) and [SeedSigner.com](https://seedsigner.com/keybase.txt). This method can be used if you would like to make an even deeper, independent inspection without relying on Keybase at all, or if the Keybase.io site is no longer valid or it is removed entirely. + +Once you have used one of these methods, you will know if the Public Key stored on Keybase, is genuinely from the SeedSinger Project or not. +

+
+
+ +If the two ID's do *not* match, then you must stop here immediately. Do not continue. Contact us for assistance in the Telegram group address above. + +
+ +### Step 2. Verifying that the *software images/binaries* are genuine + +Now that you have confirmed that you do have the real SeedSigner Project's Public Key (ie the 16 characters match) - you can return to your terminal window. Running the *shasum* command, is the final verification step and will confirm (via file hashing) that the software code/image files, were also not altered since publication, or even during your download process. +(Prior to version 0.6.0 , your verify command will check the .zip file which contains the binary files.) + + **On Linux or OSX:** Run this command ``` -The reponse to this command should include the text: -``` -seedsigner_0_4_6.img.zip: OK +shasum -a 256 --ignore-missing --check seedsigner.0.7.*.sha256.txt ``` -There are other steps you can take to verify the software, including examining the hash value in the .sha256 text file, but this one has been documented here because it seems the simplest for most people to follow. Please recognize that this process can only validate the software to the extent that the entity that first published the key is an honest actor, and assumes the private key has remained uncompromised and is not being used by a malicious actor. +**On Windows (inside Powershell):** Run this command +``` +CertUtil -hashfile seedsigner_os.0.8.0.Insert_Your_Pi_Models_binary_here_For_Example_pi02w.img SHA256 +``` +On Windows, you must then manually compare the resulting file hash value to the corresponding hash value shown inside the .SHA256 cleartext file. +
+ +Wait up to 30 seconds for the command to complete, and it should display: +``` +seedsigner_os.0.7.x.[Your_Pi_Model_For_Example:pi02w].img: OK +``` +**If you receive the "OK" message** for your **seedsigner_os.0.7.x.[Your_Pi_Model_For_Example:pi02w].img file**, as shown above, then your verification is fully complete! +**All of your downloaded files have now been confirmed as both authentic and unaltered!** You can proceed to create/write your MicroSD card😄😄 !! + +If your file result shows "FAILED", then you must stop here immediately. Do not continue. Contact us for assistance at the Telegram group address above. + +
+ +Please recognize that this process can only validate the software to the extent that the entity that first published the key is an honest actor, and their private key is not compromised or somehow being used by a malicious actor. +
+
+ + +## Writing the software onto your MicroSD card + +To write the SeedSigner software onto your MicroSD card, there are a few options available: +| Application | Description | Platform and official Source | +|--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------| +| Balena Etcher | The application is called Etcher, and the company that wrote it is called Balena. Hence *Etcher by Balena* or *Balena Etcher* | [Available for Windows, Mac and Linux](https://www.balena.io/etcher#download-etcher) | +| Raspberry Pi Imager | Produced by the Raspberry Pi organization. | [Available for Windows, Mac and Linux](https://www.raspberrypi.com/software/) | +| DD Command Line Utility | Built-in to Linux and MacOS, the DD (Data Duplicator) is a tool for advanced users. If not used carefully it can accidentally format the incorrect disk! | Built-in to Linux and MacOS | + +Be sure to download the software from the genuine publisher. +Either of the Etcher or Pi Imager software is recommended. Some SeedSigner users have reported a better experience with one or the other. So, if the one application doesn’t work well for your particular machine, then please try the other one. +
+### **General Considerations:** +The writing and verify steps are very quick from version 0.6.0 upwards, so please pay close attention to your screen. +Make sure to set any write-protection physical slider on the MicroSD Card Adapter to UN-locked. +You also don’t need to pre-format the MicroSD beforehand. You *dont* need to unzip any .zip file beforehand. +Current Etcher and Pi Imager software will perform a verify action (by default) to make sure the card was written successfully! Watching for that verify step to complete successfully, can save you a lot of headaches if you later need to troubleshoot issues where your SeedSigner device doesn’t boot up at power on. +Writing the MicroSd card is also known as flashing. +It will overwrite everything on the MicroSD card. +If the one application fails for you, then please try again using our other recommended application. +Advanced users may want to try the Linux/MacOS *DD* command instead of using Etcher or Pi Imager, however, a reminder is given that DD can overwrite the wrong disk if you are not careful ! +#### **Specific considerations for Windows users:** +Use the Pi imager software as your first choice on Windows. Windows can sometimes flag the writing of a MicroSD as risky behaviour and hence it may prevent this activity. If this happens, your writing/flashing will fail, hang or wont even begin, in which case you should to try to run the Etcher/Pi-Imager app "As administrator", (right-click and choose that option). It can also be blocked by windows security in some cases, so If you have the (non-default) *Controlled Folder Access* option set to active, try turning that *off* temporarily. + + + --------------- @@ -146,7 +286,7 @@ The upper and lower portions of the enclosure can be printed using a standard FD * [Lil Pill](https://cults3d.com/en/3d-model/gadget/lil-pill-seedsigner-case) by @_CyberNomad * [OrangeSurf Case](https://github.com/orangesurf/orangesurf-seedsigner-case) by @OrangeSurfBTC -* [PS4 Seedsigner](https://www.thingiverse.com/thing:5363525) by @Silexperience +* [PS4 SeedSigner](https://www.thingiverse.com/thing:5363525) by @Silexperience * [OpenPill Faceplate](https://www.printables.com/en/model/179924-seedsigner-open-pill-cover-plates-digital-cross-jo) by @Revetuzo * [Waveshare CoverPlate](https://cults3d.com/en/3d-model/various/seedsigner-coverplate-for-waveshare-1-3-inch-lcd-hat-with-240x240-pixel-display) by @Adathome1 @@ -162,17 +302,39 @@ You can use SeedSigner to export your seed to a hand-transcribed SeedQR format t Standard SeedQR templates: -* [12-word SeedQR template (25x25)](docs/seed_qr/printable_templates/12words_seedqr_template.pdf) -* [24-word SeedQR template (29x29)](docs/seed_qr/printable_templates/24words_seedqr_template.pdf) -* [Baseball card template: 24-word SeedQR (29x29)](docs/seed_qr/printable_templates/Seed_QR_Card.pdf) +* [12-word SeedQR template dots (25x25)](docs/seed_qr/printable_templates/dots_25x25.pdf) +* [24-word SeedQR template dots (29x29)](docs/seed_qr/printable_templates/dots_29x29.pdf) +* [12-word SeedQR template grid (25x25)](docs/seed_qr/printable_templates/grid_25x25.pdf) +* [24-word SeedQR template grid (29x29)](docs/seed_qr/printable_templates/grid_29x29.pdf) +* [Baseball card template: 24-word SeedQR (29x29)](docs/seed_qr/printable_templates/trading_card_29x29_w24words.pdf) CompactSeedQR templates: -* [12-word CompactSeedQR template (21x21)](docs/seed_qr/printable_templates/compact_seedqr/12words_compactseedqr_template.pdf) -* [24-word CompactSeedQR template (25x25)](docs/seed_qr/printable_templates/compact_seedqr/24words_compactseedqr_template.pdf) +* [12-word CompactSeedQR template dots (21x21)](docs/seed_qr/printable_templates/dots_21x21.pdf) +* [24-word CompactSeedQR template dots (25x25)](docs/seed_qr/printable_templates/dots_25x25.pdf) +* [12-word CompactSeedQR template grid (21x21)](docs/seed_qr/printable_templates/grid_21x21.pdf) +* [24-word CompactSeedQR template grid (25x25)](docs/seed_qr/printable_templates/grid_25x25.pdf) +* [Baseball card template: 12-word Compact SeedQR (21x21)](docs/seed_qr/printable_templates/trading_card_21x21_w12words.pdf) +* [Baseball card template: 24-word Compact SeedQR (25x25)](docs/seed_qr/printable_templates/trading_card_25x25_w24words.pdf) -_note: CompactSeedQR is an advanced feature that can be enabled in Settings_ + +2-sided SeedQR templates - 8 per sheet +Printing settings - (2-sided)("flip on long edge")("Actual Size") +If printing on cardstock, adjust your printer settings via its control panel + +A4 templates(210mm * 297mm): +* [21x21 - stores 12-word seeds ONLY in CompactSeedQR format ONLY](docs/seed_qr/printable_templates/21x21_A4_trading_card_2sided.pdf) +* [25x25 - stores 12-word or 24 word seeds depending on SeedQR format](docs/seed_qr/printable_templates/25x25_A4_trading_card_2sided.pdf) +* [29x29 - stores 24-word seeds ONLY as plaintext SeedQR format ONLY](docs/seed_qr/printable_templates/29x29_A4_trading_card_2sided.pdf) + +Letter templates(8.5in * 11in): +* [21x21 - stores 12-word seeds ONLY in CompactSeedQR format ONLY](docs/seed_qr/printable_templates/21x21_letter_trading_card_2sided.pdf) +* [25x25 - stores 12-word or 24 word seeds depending on format](docs/seed_qr/printable_templates/25x25_letter_trading_card_2sided.pdf) +* [29x29 - stores 24-word seeds ONLY as plaintext SeedQR format ONLY](docs/seed_qr/printable_templates/29x29_letter_trading_card_2sided.pdf) --------------- -# Manual Installation Instructions -see the docs: [Manual Installation Instructions](docs/manual_installation.md) +# Build from Source +See the [SeedSigner OS repo](https://github.com/SeedSigner/seedsigner-os/) for instructions. + +# Developer Local Build Instructions +Raspberry Pi OS is commonly used for development. See the [Raspberry Pi OS Build Instructions](docs/raspberry_pi_os_build_instructions.md) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..17c45115 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3.7' + +services: + seedsigner-dev: + build: + context: . + dockerfile: docker/Dockerfile + command: sh -c 'bash -c "docker/setup.sh"' + volumes: + - ../seedsigner:/seedsigner \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..32a1a7ca --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.10-bullseye + +# install zbar dependencyy +RUN apt-get -qq update +RUN apt-get -y -qq install zbar-tools + +# temp copy requirements files to local repo to do pip3 install +COPY ../requirements.txt /requirements.txt +COPY ../tests/requirements.txt /tests-requirements.txt + +WORKDIR / +RUN pip3 install -r requirements.txt +RUN pip3 install -r tests-requirements.txt + +# clean up copied files +RUN rm /requirements.txt +RUN rm /tests-requirements.txt + +# set working dir +WORKDIR /seedsigner \ No newline at end of file diff --git a/docker/setup.sh b/docker/setup.sh new file mode 100755 index 00000000..d31b5f9c --- /dev/null +++ b/docker/setup.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +pip3 install -e . +tail -f /dev/null \ No newline at end of file diff --git a/docs/dice_verification.md b/docs/dice_verification.md index e4a5212c..7e3833b4 100644 --- a/docs/dice_verification.md +++ b/docs/dice_verification.md @@ -1,40 +1,274 @@ -## Verifying dice seed generation +# Verifying dice seed generation -It is possible to do a 'dry run' to verify that seed generation has not been tempered. -This will ensure that the derivation algorithm has not been tempered and is still same as well known algorithm used bu coldcard and ian coleman bip39 webpage. -For example an 'evil maid' attack would be someone accesing your sdcard and change the code to only take in account 5 or 6 dice, so the 24 words would still feel random but the attacker will need to brute force only 5 or 6 dice (very easy). -This is part of the "do not trust, verify" crypto mottos. +The intention of this documentation is to describe how to verify the seed generation code used in SeedSigner against other independent tools, to prove that they all generate the same results, despite them using different programming languages and code libraries.

+As it is an important step to verify all software releases being used to ensure that the installation files downloaded have not been compromised, the same is true especially for the seed generation procedure which unknowingly might not work as expected due to bugs or even on purpose.

+This guide describes how this can be achieved.

+As usual: Don't Trust, Verify!
+

+**Note:**
+**Do NOT use this with any seed you want to use later with real funds. This exercise is only for checking that the independent codebases get to the same end result!**
+**However, if you do want to check your real seedphrases you should download the Iancoleman and/or Bitcoiner.Guide tools onto an airgapped, ephemeral computer (e.g. using tails-OS) and perform these tests on there. Destroy/abandon the TailsOS afterwards.**
+**Never input seed phrases that you intend to use to store real funds onto an internet-connected computer!!!** +


-### Verifying with Ian coleman webpage +## 99 Dice Rolls / 24 Seed Words Example -Go to https://iancoleman.io/bip39 and check show entropy detail: - +The following 99 dice roll results are used in the verification steps as an example for a 24 words seed:
+> 655152231316521321611331544441236164664431121534415633526456254462245546236542364246312613322234612 -And then make sure to check 'Hex' or 'base 10' (1) and 24 words as mnemonic length (2). -Do not use 'dice' format because dice 6 will be replaced by 0. -And then enter the 99 dices numbers in (3) : - +The corresponding 24 seed words are:
+> eyebrow obvious such suggest poet seven breeze blame virtual frown dynamic donor harsh pigeon express broccoli easy apology scatter force recipe shadow claim radio -When the 99 dice number has been entered in the seedsigner and ian coleman page, you will be able to verify that the 24 words are the same. +(Scroll down near the end to see result values for a 50 dice rolls / 12 seed words example) +


+ +## Creating seed via Dice rolls in SeedSigner (here v0.6.0) + +First we create a new seed based on dice rolls in SeedSigner:

+Power on your SeedSigner, go to the 'Tools' menu and select 'New Seed' (with the dice symbols):
+ + +Select '24 words (99 rolls)' and on the next screen enter the dice numbers one after another:
+ + +Go on until the end (99 dice roll numbers):
+ + +After that the 24 seed words are shown (in 6 screens of 4 words each):
+ +
**.....**
+ + +The fingerprint for this seed is:
+ +


+Having now created a dice-based seed in the SeedSigner, we will go on to compare those details to what appears in the: +* Sparrow desktop wallet software +* Ian Coleman's Mnemonic Code Converter website +* Seed Tool website + +We will create a wallet to have the complete zpub and receive/change addresses to check against the two web pages.
+SeedSigner currently supports BlueWallet, Nunchuk, Sparrow and Specter Desktop. Here we will use Sparrow wallet as an example.
+ +Keep the SeedSigner open and the newly created seed still loaded as we will need it in the next step. +


+ +## Create new wallet from seed in Sparrow Wallet to see xpub/zpub and addresses + +Go to https://www.sparrowwallet.com/download/ and download the release version supported by your operating system.

+Open Sparrow Wallet, go to 'File' menu and select 'New Wallet'. Enter a name (e.g. test), and click 'Create Wallet'.

+Click 'Airgapped Hardware Wallet' (1) and click on the 'Scan' button in the SeedSigner entry (2) which will open the camera scan screen:
+ + +On SeedSigner go to the seed just created and click 'Export Xpub':
+ + +Follow these menu entries in SeedSigner:
+> Export Xpub --> Single Sig --> Native Segwit --> Sparrow
+ + + +Click 'Export Xpub' and SeedSigner will show an animated QR code to be scanned in Sparrow Wallet (where we are still in the wallet creation).
+Scan the QR code SeedSigner is showing in Sparrow Wallet.

+The wallet has now been created in Sparrow. Click 'Apply' button to finalize. The wallet's settings screen now looks like this:
+ + +We will later use this to verify: (1) fingerprint, (2) zpub (click this button to switch between xpub and zpub!) and (3) addresses on the 'Addresses' tab. +


+ +## Verifying with Ian Coleman BIP39 website + +Go to https://iancoleman.io/bip39 and check 'Show entropy details' (1):
+ + +
+Make sure to check (1) 'Hex' and (2) '24 Words' as 'Mnemonic Length'.
+(Do not use 'dice' format because dice 6 will be replaced by 0).
+Then enter the 99 dices numbers in (3). The corresponding seed words are shown in (4):
+ +

+The 24 seed words are the same in SeedSigner and the Ian Coleman tool. +

+ +### Verification of (1) fingerprint, (2) zpub and (3) generated addresses +**Fingerprint:**
+Fingerprint is not shown in the Ian Coleman tool (so cannot be verified here) +

+**Zpub:**
+Scroll down to the 'Derivation Path' section, click on the 'BIP84' tab (1) and find the zpub in (2): +

+Compare to zpub in Sparrow:
+ +
+ +Zpub is the same as shown in SeedSigner and Sparrow wallet. +

+**Addresses:**
+Scroll down to the 'Derived Addresses' section and compare the receive addresses to the ones generated in Sparrow ('Addresses' tab of the wallet): + +
+ +Check that the receive addresses all match.

+To verify the change addresses, change 'External / Internal' to 1 (1): +

+Compare the change addresses to the ones generated in Sparrow ('Addresses' tab of the wallet): + +
+ +Check that the change addresses all match. +


+ +## Verifying with Seed Tool website + +Go to https://bitcoiner.guide/seed/ and click on 'Seed Generation Input' (1):
+ + +Then click on the 'Show the Entropy Section' tab (1):
+ + +Enter the 99 dice numbers in (1), in (2) change back to 'Hex', check that (3) is still '24 Words' and the calculated seed words are shown in (4):
+ + +Seed words shown are the same as in SeedSigner and the Ian Coleman web tool seen before. +

+ +### Verification of (1) fingerprint, (2) zpub and (3) generated addresses + +**Fingerprint:**
+Fingerprint can be seen here (1):
+

+**Zpub:**
+Scroll down to the 'Derived Addresses' section (1), click on it, make sure that '84' is selected for 'Purpose' (2) and check the zpub at (3):
+

+Compare to zpub in Sparrow:
+ +
+ +Zpub is the same as shown in SeedSigner, Sparrow and Ian Colemand tool. +

+**Addresses:**
+Scroll down a little bit where the receive addresses are shown and compare to the ones generated in Sparrow ('Addresses' tab of the wallet):
+ +
+ +Check that the receive addresses all match.

+To verify the change addresses, change the 'Receive/Change' dropdown box to '1 (Change)' (1): +

+Compare the change addresses to the ones generated in Sparrow ('Addresses' tab of the wallet): + +
+ +Check that the change addresses all match. +


+ +## 50 Dice Rolls / 12 Seed Words Example + +SeedSigner supports the creation of mnenomic seeds both with 12 or 24 seed words corresponding to 50 or 99 dice rolls. Below are some example result values for using 50 dice rolls only.

+All the steps shown can be executed the same way, just select the '12 words (50 rolls)' option in SeedSigner and change the 'Mnenomic Length' dropdown boxes in both web tools to '12 Words'.

+ +50 dice roll results as an example for a 12 words seed:
+> 65515223131652132161133154444123616466443112153441 + +The corresponding 12 seed words are:
+> hole luggage safe present express tragic orbit shed switch metal identify path + +Fingerprint:
+> 8d9cced8 + +Zpub:
+> zpub6qf9ziL759pzyhKMWaPfNSiCETkoA6oq3fbCDvXqcURiMtPnkEg3nH93W5mrSkvGPoJC9xTYZheYDsYoiYc5AkSk9iY3DkCJHkFgHMdijW6 + +Addresses:
+Receive:
+> bc1q00lln3r4mt4uwvg7mxv96xgpewauwmggkex2ff
+> bc1q0jj2cv965f3642mv4lgq5za80jtfpkd0jhjefr
+> bc1qpecssejm2678v0rknk9tsxd5fsshezfr0vr5m5
+> bc1qcsl37xn5rkfz8qhcfwq5u52acxecyyfz0kv4gh
+> bc1q8ehx53re0wck4m9tzek8mlnctp2ztm7jq94zm4
+> ...
+ +Change:
+> bc1qz0ckhg3m349qpmweyn5v6r6tvx2wfw4nv8h75q
+> bc1qme5tu2t424ws3z69u445q0yw88vpc7fwygra4r
+> bc1qznuyuc087ky7fhv4nvlmll4p586ks4ggcyt36d
+> bc1qjcqxv22j0g00pehruwwh34sw5znu6vp3myaspy
+> bc1q6dpfl7czd22wt0max6p09vr6lvvpag7xw9u8lc
+> ... + +

+## Conclusion + +What did we achieve now?
+ +We created a dice-based seed in SeedSigner and set up a wallet using this seed in Sparrow wallet (as this is what a seed is used for).

+We double-checked in two different web tools implementing different methods for seed creation that what SeedSigner generates perfectly matches up with what the other tools calculate based on the same dice entropy used.
+ +So congratulations if the fingerprints, zpubs and addresses all match up in your example so you can be much more confident that nothing is wrong with your generated seed. + +--- + +# Command Line Tool +_(for more advanced/python-savvy users)_ + +Run the exact same SeedSigner mnemonic generation code from the command line to quickly test and externally verify the results. + +Create a python virtualenv (out of the scope of this doc) and install dependencies: +```bash +pip3 install embit + +# Install the main project code to make it importable +pip3 install -e . +``` -#### Here in real life: +Then run the utility script with `-h` to view the usage instructions: +```bash +cd tools +python3 mnemonic.py -h +``` -We start with first 3 roll, it is important to always read dice roll from left to right to avoid human bias: - +``` + Verify SeedSigner's dice rolls and coin flip entropy-to-mnemonic conversion via this tool. -Then we arrive at the 99th dice: - + Compare its results against iancoleman.io/bip39 and bitcoiner.guide/seed -Then same 24 words! : - + Usage: + # 50 dice rolls / 12-word mnemonic + python3 mnemonic.py dice 5624433434... + + # 99 dice rolls / 24-word mnemonic + python3 mnemonic.py dice 6151463561... -Now we are sure that the dice derivation is correct and we can unplug everything and do a new dice roll only on the seedsigner. + # 50 dice rolls, entered as 0-5 / 12-word mnemonic + python3 mnemonic.py --zero-indexed-dice dice 5135535514... -### Verifying with Coldcard + # 128 coin flips / 12-word mnemonic + python3 mnemonic.py coins 1111100111... -There is nothing specific, the algorithm are completely the same. Coldcard has a verification script in python and all explanations here: -https://coldcard.com/docs/verifying-dice-roll-math + # 256 coin flips / 24-word mnemonic + python mnemonic.py coins 0010111010... -### Epilogue -You can use these methods to do dry run time to time to verify that no one has changed the micro sdcard. But do not use the generated 24 words as a valid wallet, they need to be generated alone, only on the seedsigner! + # GENERATE 50 random dice rolls / 12-word mnemonic + python3 mnemonic.py dice rand12 + + # GENERATE 99 random dice rolls / 24-word mnemonic + python3 mnemonic.py dice rand24 + + # GENERATE 99 random dice rolls, entered as 0-5 / 24-word mnemonic + python3 mnemonic.py --zero-indexed-dice dice rand24 + + # GENERATE 128 random coin flips / 12-word mnemonic + python3 mnemonic.py coins rand12 + + # GENERATE 256 random coin flips / 24-word mnemonic + python3 mnemonic.py coins rand24 +``` + +### How to get the same results in iancoleman.io +Always specify your expected length in the "Mnemonic Length" droplist (defaults to "Use Raw Entropy (3 words per 32 bits)"). + +Dice Rolls: Do NOT use the "Dice [1-6]" option; select "Base 10 [0-9]" or "Hex [0-9A-F]" + +Zero-indexed dice rolls: Select "Base 6 [0-5]", "Base 10 [0-9]", or "Hex [0-9A-F]" + +Coin Flips: Select "Binary [0-1]", "Base 6 [0-5]", "Base 10 [0-9]", or "Hex [0-9A-F]" 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/img/Mini_Pill_Main_Photo.jpg b/docs/img/Mini_Pill_Main_Photo.jpg new file mode 100644 index 00000000..bf52af86 Binary files /dev/null and b/docs/img/Mini_Pill_Main_Photo.jpg differ diff --git a/docs/img/Open_Pill_Mini_Models.JPG b/docs/img/Open_Pill_Mini_Models.JPG new file mode 100644 index 00000000..47f69a84 Binary files /dev/null and b/docs/img/Open_Pill_Mini_Models.JPG differ diff --git a/docs/img/Open_Pill_Mini_w_Faceplate.JPG b/docs/img/Open_Pill_Mini_w_Faceplate.JPG new file mode 100644 index 00000000..68a9ceff Binary files /dev/null and b/docs/img/Open_Pill_Mini_w_Faceplate.JPG differ diff --git a/docs/img/Open_Pill_Models.JPG b/docs/img/Open_Pill_Models.JPG new file mode 100644 index 00000000..f687faa0 Binary files /dev/null and b/docs/img/Open_Pill_Models.JPG differ diff --git a/docs/img/Open_Pill_Star.JPG b/docs/img/Open_Pill_Star.JPG index eb312aa9..d42d2b9a 100644 Binary files a/docs/img/Open_Pill_Star.JPG and b/docs/img/Open_Pill_Star.JPG differ diff --git a/docs/img/Open_Pill_w_Comfort_Joystick.png b/docs/img/Open_Pill_w_Comfort_Joystick.png new file mode 100644 index 00000000..c4d21f76 Binary files /dev/null and b/docs/img/Open_Pill_w_Comfort_Joystick.png differ diff --git a/docs/img/Orange_Pill_Models.JPG b/docs/img/Orange_Pill_Models.JPG new file mode 100644 index 00000000..40a5015f Binary files /dev/null and b/docs/img/Orange_Pill_Models.JPG differ diff --git a/docs/img/Rugged_Pill_Thumb.jpg b/docs/img/Rugged_Pill_Thumb.jpg new file mode 100644 index 00000000..d40b878f Binary files /dev/null and b/docs/img/Rugged_Pill_Thumb.jpg differ diff --git a/docs/img/Simple_Pill_Models.JPG b/docs/img/Simple_Pill_Models.JPG new file mode 100644 index 00000000..5a4a36fd Binary files /dev/null and b/docs/img/Simple_Pill_Models.JPG differ diff --git a/docs/img/dicedoc/coleman_addresses.png b/docs/img/dicedoc/coleman_addresses.png new file mode 100644 index 00000000..e9200e9f Binary files /dev/null and b/docs/img/dicedoc/coleman_addresses.png differ diff --git a/docs/img/dicedoc/coleman_change_addresses_1.png b/docs/img/dicedoc/coleman_change_addresses_1.png new file mode 100644 index 00000000..d1c62cda Binary files /dev/null and b/docs/img/dicedoc/coleman_change_addresses_1.png differ diff --git a/docs/img/dicedoc/coleman_change_addresses_2.png b/docs/img/dicedoc/coleman_change_addresses_2.png new file mode 100644 index 00000000..f809cb8f Binary files /dev/null and b/docs/img/dicedoc/coleman_change_addresses_2.png differ diff --git a/docs/img/dicedoc/coleman_entropy.png b/docs/img/dicedoc/coleman_entropy.png new file mode 100644 index 00000000..8f9ad7f8 Binary files /dev/null and b/docs/img/dicedoc/coleman_entropy.png differ diff --git a/docs/img/dicedoc/coleman_verify.png b/docs/img/dicedoc/coleman_verify.png new file mode 100644 index 00000000..c976fe38 Binary files /dev/null and b/docs/img/dicedoc/coleman_verify.png differ diff --git a/docs/img/dicedoc/coleman_zpub.png b/docs/img/dicedoc/coleman_zpub.png new file mode 100644 index 00000000..f69e3e90 Binary files /dev/null and b/docs/img/dicedoc/coleman_zpub.png differ diff --git a/docs/img/dicedoc/seedtool_1.png b/docs/img/dicedoc/seedtool_1.png new file mode 100644 index 00000000..2b4ed01c Binary files /dev/null and b/docs/img/dicedoc/seedtool_1.png differ diff --git a/docs/img/dicedoc/seedtool_2.png b/docs/img/dicedoc/seedtool_2.png new file mode 100644 index 00000000..6c4f252d Binary files /dev/null and b/docs/img/dicedoc/seedtool_2.png differ diff --git a/docs/img/dicedoc/seedtool_3.png b/docs/img/dicedoc/seedtool_3.png new file mode 100644 index 00000000..cc52e853 Binary files /dev/null and b/docs/img/dicedoc/seedtool_3.png differ diff --git a/docs/img/dicedoc/seedtool_addresses.png b/docs/img/dicedoc/seedtool_addresses.png new file mode 100644 index 00000000..8c6cfc36 Binary files /dev/null and b/docs/img/dicedoc/seedtool_addresses.png differ diff --git a/docs/img/dicedoc/seedtool_change_addresses_1.png b/docs/img/dicedoc/seedtool_change_addresses_1.png new file mode 100644 index 00000000..9f2e7eb3 Binary files /dev/null and b/docs/img/dicedoc/seedtool_change_addresses_1.png differ diff --git a/docs/img/dicedoc/seedtool_change_addresses_2.png b/docs/img/dicedoc/seedtool_change_addresses_2.png new file mode 100644 index 00000000..99c41186 Binary files /dev/null and b/docs/img/dicedoc/seedtool_change_addresses_2.png differ diff --git a/docs/img/dicedoc/seedtool_fingerprint.png b/docs/img/dicedoc/seedtool_fingerprint.png new file mode 100644 index 00000000..c1ef3b04 Binary files /dev/null and b/docs/img/dicedoc/seedtool_fingerprint.png differ diff --git a/docs/img/dicedoc/seedtool_zpub.png b/docs/img/dicedoc/seedtool_zpub.png new file mode 100644 index 00000000..e43a38a0 Binary files /dev/null and b/docs/img/dicedoc/seedtool_zpub.png differ diff --git a/docs/img/dicedoc/sesi_dice_1.png b/docs/img/dicedoc/sesi_dice_1.png new file mode 100644 index 00000000..b85f7a6c Binary files /dev/null and b/docs/img/dicedoc/sesi_dice_1.png differ diff --git a/docs/img/dicedoc/sesi_dice_2.png b/docs/img/dicedoc/sesi_dice_2.png new file mode 100644 index 00000000..4c73d96b Binary files /dev/null and b/docs/img/dicedoc/sesi_dice_2.png differ diff --git a/docs/img/dicedoc/sesi_export_xpub_1.png b/docs/img/dicedoc/sesi_export_xpub_1.png new file mode 100644 index 00000000..601b7aac Binary files /dev/null and b/docs/img/dicedoc/sesi_export_xpub_1.png differ diff --git a/docs/img/dicedoc/sesi_export_xpub_2.png b/docs/img/dicedoc/sesi_export_xpub_2.png new file mode 100644 index 00000000..f43eea5e Binary files /dev/null and b/docs/img/dicedoc/sesi_export_xpub_2.png differ diff --git a/docs/img/dicedoc/sesi_finger_print.png b/docs/img/dicedoc/sesi_finger_print.png new file mode 100644 index 00000000..de15f023 Binary files /dev/null and b/docs/img/dicedoc/sesi_finger_print.png differ diff --git a/docs/img/dicedoc/sesi_seed_1.png b/docs/img/dicedoc/sesi_seed_1.png new file mode 100644 index 00000000..7693d674 Binary files /dev/null and b/docs/img/dicedoc/sesi_seed_1.png differ diff --git a/docs/img/dicedoc/sesi_seed_2.png b/docs/img/dicedoc/sesi_seed_2.png new file mode 100644 index 00000000..87b2f139 Binary files /dev/null and b/docs/img/dicedoc/sesi_seed_2.png differ diff --git a/docs/img/dicedoc/sesi_tools_dice_seed.png b/docs/img/dicedoc/sesi_tools_dice_seed.png new file mode 100644 index 00000000..1eeec191 Binary files /dev/null and b/docs/img/dicedoc/sesi_tools_dice_seed.png differ diff --git a/docs/img/dicedoc/sparrow_wallet_1.png b/docs/img/dicedoc/sparrow_wallet_1.png new file mode 100644 index 00000000..02d24af6 Binary files /dev/null and b/docs/img/dicedoc/sparrow_wallet_1.png differ diff --git a/docs/img/dicedoc/sparrow_wallet_2.png b/docs/img/dicedoc/sparrow_wallet_2.png new file mode 100644 index 00000000..cf459a4a Binary files /dev/null and b/docs/img/dicedoc/sparrow_wallet_2.png differ diff --git a/docs/img/dicedoc/sparrow_zpub.png b/docs/img/dicedoc/sparrow_zpub.png new file mode 100644 index 00000000..ccd22ab8 Binary files /dev/null and b/docs/img/dicedoc/sparrow_zpub.png differ diff --git a/docs/img/legacy_hardware_remapped_pins.jpg b/docs/img/legacy_hardware_remapped_pins.jpg new file mode 100644 index 00000000..f6af8b47 Binary files /dev/null and b/docs/img/legacy_hardware_remapped_pins.jpg differ diff --git a/docs/img/legacy_hardware_remapped_pins_photo.jpg b/docs/img/legacy_hardware_remapped_pins_photo.jpg new file mode 100644 index 00000000..a7abcccc Binary files /dev/null and b/docs/img/legacy_hardware_remapped_pins_photo.jpg differ diff --git a/docs/legacy_hardware.md b/docs/legacy_hardware.md new file mode 100644 index 00000000..09daaaab --- /dev/null +++ b/docs/legacy_hardware.md @@ -0,0 +1,22 @@ +# Legacy Hardware Modifications + +Older Raspberry Pi devices have a smaller GPIO header, 26 pin as opposed to the 40 pin header found on more recent models. The Waveshare LCD hat uses some of the pins between 26 and 40 for the buttons, meaning that these will need to be reassigned (and remapped in software) when used with an older device. + +## Hardware Changes + +A suggested remapping can be found here: +![Remapped Pins Shematic](./img/legacy_hardware_remapped_pins.jpg) + +This remapping can be done by soldering wires on to the Waveshare hat as below: +![Remapped Pins Photo](./img/legacy_hardware_remapped_pins_photo.jpg) + +Alternatively, you could do this by connecting the LCD hat on with individual breadboard jumper wires. + +Once you have re-mapped the pins, it is advised to do an IO Test to ensure that everything works. + +**Warning: Some of the GPIO pins on contain 5 volt output. Raspberry Pi GPIO pins are NOT 5V tolerant, meaning that if you accidentally connect a 5V supply pin to a GPIO input pin, you risk permanent damage.** + +## Software Changes +The Seedsigner software will automatically detect which hardware revision you are using and if the older hardware is detected, will remap the software to match the above modifications to the Waveshare hat. + +If you are using a pre-built Seedsigner image that hasn't yet has this incorporated, you can simply take the file "buttons.py" (/src/seedsigner/hardware/ int his repository) and overwrite same file on your Seedsigner SD card. (The easiest way to do this is to copy it on to the /boot/ partitition of the SD card then copy it over via the command line while connected to your Pi via monitor+keyboard) O \ No newline at end of file diff --git a/docs/manual_installation.md b/docs/raspberry_pi_os_build_instructions.md similarity index 59% rename from docs/manual_installation.md rename to docs/raspberry_pi_os_build_instructions.md index 236d77c4..fdfb894e 100644 --- a/docs/manual_installation.md +++ b/docs/raspberry_pi_os_build_instructions.md @@ -1,25 +1,34 @@ -# Manual Installation Instructions +# Raspberry Pi OS Local Dev Build Instructions -Begin by acquiring a specific copy of the Raspberry Pi Lite operating system, dated 2021-05-28; this version can be found here: +Since v0.6.0, official releases use our custom [SeedSigner OS](https://github.com/SeedSigner/seedsigner-os/) However, project contributors looking to do rapid development cycles typically use the older Raspberry Pi OS that we had previously built on prior to v0.6.0. If you're here to set up your SeedSigner for local development, continue reading. -https://downloads.raspberrypi.org/raspios_lite_armhf/images/raspios_lite_armhf-2021-05-28/ +Begin by acquiring the latest 32-bit, Buster-based Raspberry Pi Lite operating system. This guide was tested using the version dated 2023-05-03; which can be found here: -Best practice is to verify the downloaded .zip file containing the Raspberry Pi Lite OS matches the published SHA256 hash of the file; for additional reference that hash is: c5dad159a2775c687e9281b1a0e586f7471690ae28f2f2282c90e7d59f64273c. After verifying the file's data integrity, you can decompress the .zip file to obtain the operating system image that it contains. You can then use Balena's Etcher tool (https://www.balena.io/etcher/) to write the Raspberry Pi Lite software image to a memory card (4 GB or larger). It's important to note that an image authoring tool must be used (the operating system image cannot be simply copied into a file storage partition on the memory card). +https://downloads.raspberrypi.org/raspios_oldstable_lite_armhf/images/raspios_oldstable_lite_armhf-2023-05-03/ -The manual SeedSigner installation and configuration process requires an internet connection on the device to download the necessary libraries and code. But because the Pi Zero 1.3 does not have onboard wifi, you have two options: +SeedSigner does not work any of the more recent versions of Debian. This is a known limitation and there are open tickets to track the progress of this ([Debian 11 ticket](https://github.com/SeedSigner/seedsigner/issues/431), [Debian 12 ticket](https://github.com/SeedSigner/seedsigner/issues/430)). This guide does not work on the 64-bit versions of Buster, however pull requests to update it to be compatible are welcome. -1. Run these steps on a separate Raspberry Pi 2/3/4 or Zero W which can connect to the internet and then transfer the SD card to the Pi Zero 1.3 when complete. -2. OR configure the Pi Zero 1.3 directly by relaying through your computer's internet connection over USB. See instructions [here](usb_relay.md). +Best practice is to verify the downloaded file containing the Raspberry Pi Lite OS matches the published SHA256 hash of the file; for additional reference that hash is: 3d210e61b057de4de90eadb46e28837585a9b24247c221998f5bead04f88624c. After verifying the file's data integrity, you can decompress the .tar.xz file to obtain the operating system image that it contains. You can then use Balena's Etcher tool (https://www.balena.io/etcher/) to write the Raspberry Pi Lite software image to a memory card (4 GB or larger). It's important to note that an image authoring tool must be used (the operating system image cannot be simply copied into a file storage partition on the memory card). +The manual SeedSigner installation and configuration process requires an internet connection on the Pi to download the necessary libraries and code. +If your Pi does not have onboard wifi, you have two options: + +1. Run these steps on a separate Raspberry Pi 2/3/4 or Zero W which does have onboard Wi-Fi to connect to the internet, and then move the SD card over to the non Wi-Fi enabled Pi when complete. +2. OR configure the non Wi-Fi enabled Pi directly by relaying through your computer's internet connection over USB. See instructions [here](usb_relay.md). + +If your Pi does have onboard Wi-Fi, then using the Rasberry Pi Imager software will allow you to easily configure your Pi's Wi-Fi connection, as well as simultaneously write the image file. That will make your initial SSH into the Pi much easier. +Use the Pi's onboard Wi-Fi only if you are setting up a local development environment, never for real funds or binary image creation. + For the following steps you'll need to either connect a keyboard & monitor to the network-connected Raspberry Pi you are working with, or SSH into the Pi if you're familiar with that process. ### Configure the Pi First things first, verify that you are using the correct version of the Raspberry Pi Lite operating system by typing the command: -``` +```bash cat /etc/os-release ``` + The output of this command should match the following text: -``` +```bash PRETTY_NAME="Raspbian GNU/Linux 10 (buster)" NAME="Raspbian GNU/Linux" VERSION_ID="10" @@ -33,7 +42,7 @@ BUG_REPORT_URL="http://www.raspbian.org/RaspbianBugs" ``` Now launch the Raspberry Pi's System Configuration tool using the command: -``` +```bash sudo raspi-config ``` @@ -45,25 +54,65 @@ Set the following: * `Locale`: arrow up and down through the list and select or deselect languages with the spacebar. * Deselect the default language option that is selected * Select `en_US.UTF-8 UTF-8` for US English + * Use the `TAB` button to select `Ok` and press `ENTER` + * On the next screen select `en_US.UTF-8` for the default locale * You will also need to configure the WiFi settings if you are using the #1 option above to connect to the internet When you exit the System Configuration tool, you will be prompted to reboot the system; allow the system to reboot and continue with these instructions. +Each command should be run individually,unless its specified as a multi-line command. ### Change the default password Change the system's default password from the default "raspberry". Run the command: -``` +```bash passwd ``` You will be prompted to enter the current password ("raspberry") and then to enter a new password twice. In our prepared release image, the password used is `AirG@pped!`. -### Install dependencies -Copy this entire box and run it as one command (will take 15-20min to complete): +### Install python3.10 +```bash +# install compiler dependencies; takes ~1 minute on a Pi Zero 1.3 +# * openssl, libssl-dev: ssl support when pip fetches packages +# * libsqlite3-dev: required by `coverage` +sudo apt update && sudo apt install -y build-essential zlib1g-dev \ + libncurses5-dev libgdbm-dev libnss3-dev openssl libssl-dev \ + libreadline-dev libffi-dev wget libsqlite3-dev + +# Grab the python3.10 source +wget https://www.python.org/ftp/python/3.10.10/Python-3.10.10.tgz +tar -xzvf Python-3.10.10.tgz +cd Python-3.10.10 + +# Takes ~6 minutes on a Pi Zero 1.3 to check what is available +./configure --enable-optimizations + +# compiling takes ~80 minutes(!!) on a Pi Zero 1.3 +sudo make altinstall + +# cleanup +cd .. +sudo rm -rf Python-3.10.10* + +# Make python3.10 the default version +sudo update-alternatives --install /usr/bin/python python /usr/local/bin/python3.10 1 +sudo update-alternatives --install /usr/bin/python3 python3 /usr/local/bin/python3.10 1 ``` -sudo apt-get update && sudo apt-get install -y wiringpi python3-pip \ - python3-numpy python-pil libopenjp2-7 git python3-opencv \ - python3-picamera libatlas-base-dev qrencode + +Manually re-install `python3-apt` to avoid error messages in later steps (though, ironically, you will see the "ModuleNotFoundError: No module named 'apt_pkg'" error message during the `apt remove` step): +```bash +sudo apt remove --purge python3-apt -y +sudo apt autoremove -y +sudo apt install python3-apt -y +``` + + +### Install dependencies +Copy this entire box and run it as one command (~15 minutes on a Pi Zero 1.3): +```bash +sudo apt update && sudo apt install -y wiringpi python3-pip \ + python-pil libjpeg-dev zlib1g-dev libopenjp2-7 \ + git python3-opencv python3-picamera libatlas-base-dev qrencode ``` ### Install `zbar` @@ -72,17 +121,17 @@ sudo apt-get update && sudo apt-get install -y wiringpi python3-pip \ SeedSigner requires `zbar` at 0.23.x or higher. Download the binary: -``` +```bash curl -L http://raspbian.raspberrypi.org/raspbian/pool/main/z/zbar/libzbar0_0.23.90-1_armhf.deb --output libzbar0_0.23.90-1_armhf.deb ``` And then install it: -``` +```bash sudo apt install ./libzbar0_0.23.90-1_armhf.deb ``` Cleanup: -``` +```bash rm libzbar0_0.23.90-1_armhf.deb ``` @@ -90,7 +139,7 @@ rm libzbar0_0.23.90-1_armhf.deb This library "provides functions for reading digital inputs and setting digital outputs, using SPI and I2C, and for accessing the system timers." Run each of the following individual steps: -``` +```bash wget http://www.airspayce.com/mikem/bcm2835/bcm2835-1.60.tar.gz tar zxvf bcm2835-1.60.tar.gz cd bcm2835-1.60/ @@ -101,96 +150,68 @@ rm bcm2835-1.60.tar.gz sudo rm -rf bcm2835-1.60 ``` -### Set up `virtualenv` -``` -pip3 install virtualenvwrapper -``` - -Edit your bash profile with the command `nano ~/.profile` and add the following to the end: -``` -export WORKON_HOME=$HOME/.envs -export VIRTUALENVWRAPPER_PYTHON=/usr/bin/python3 -source /home/pi/.local/bin/virtualenvwrapper.sh -``` -Then `CTRL-X` and `y` to exit and save changes. - -Now create the python virtualenv for SeedSigner with these two commands: -``` -source ~/.profile -mkvirtualenv --python=python3 seedsigner-env -``` - -For convenience you can configure your `.profile` to auto-activate the SeedSigner virtualenv when you ssh in. Once again `nano ~/.profile` and add at the end: -``` -workon seedsigner-env -``` - -Optional: If you're going to be testing new code on the SeedSigner, you'll find yourself often needing to kill the SeedSigner code that automatically runs at startup (we'll be configuring this further down). As an extra convenience you can list the process id so that you can then kill it from the terminal: -``` -ps aux | grep main.py -``` - -Save your changes with `CTRL-X` and `y`. - -Now when you `ssh` in you'll see something like: -``` -pi 297 65.4 9.7 74096 36736 ? Rsl 09:26 10:29 /home/pi/.envs/seedsigner-env/bin/python main.py -pi 857 0.0 0.4 7332 1876 pts/0 S+ 09:42 0:00 grep --color=auto main.py -``` - -The top line is our SeedSigner code running. To stop it, run: -``` -kill 297 -``` -Where `297` is the process id listed in the output above (it'll be different each time). - - ### Download the SeedSigner code: -``` +```bash git clone https://github.com/SeedSigner/seedsigner cd seedsigner ``` -If you want to run a specific branch within the main SeedSigner repo, switch to it with: -``` -git checkout yourtargetbranch -``` +### Adding swap space +Compiling the dependencies requires more RAM than is available on a Raspberry +Pi 3B, let alone a Zero. Temporarily adding 1GB of additional swap space will +work around this limitation. The `/swapfile` can be deleted after you reboot. -And if you want to test a pull request (PR), for example PR #123: -``` -git fetch origin pull/123/head:pr_123 -git checkout pr_123 -``` - -where `pr_123` is any name you want to give to the new branch in your local repo that will hold the PR. +If building on a Raspberry Pi board with more than 1GB of RAM, this step can +be safely skipped. +```bash +sudo dd if=/dev/zero of=/swapfile bs=4096 count=$((1024*256)) +sudo chmod 0600 /swapfile +sudo mkswap /swapfile +sudo swapon /swapfile +``` ### Install Python `pip` dependencies: -``` -pip3 install -r requirements.txt +```bash +# Takes 1hr 15min on a Pi Zero 1.3 +python3 -m pip install -r requirements.txt + +# Only takes ~100 seconds +python3 -m pip install -r requirements-raspi.txt ``` #### `pyzbar` -Note: The `requirements.txt` installs a fork of the python `pyzbar` repo (for now pointing to the fork in Keith's `kdmukai` github account [https://github.com/kdmukai/pyzbar](https://github.com/kdmukai/pyzbar)). +Note: The `requirements.txt` installs a fork of the python `pyzbar` repo. The fork is required because the main `pyzbar` repo has been abandoned. This [github issue](https://github.com/NaturalHistoryMuseum/pyzbar/issues/124#issuecomment-971967091) discusses the changes needed in order to support reading binary data from `zbar`, which is required for our `CompactSeedQR` format which writes byte data instead of strings. The changes specifically reference the following PRs which have already been merged into Keith's fork: * [PR 76](https://github.com/NaturalHistoryMuseum/pyzbar/pull/76/files): enables scanning to continue even when a null byte (`x\00`) is found. -* [PR 82](https://github.com/NaturalHistoryMuseum/pyzbar/pull/82): enable `zbar`'s new binary mode. Note that this PR has a trivial bug that was fixed in Keith's fork. +* [PR 82](https://github.com/NaturalHistoryMuseum/pyzbar/pull/82): enable `zbar`'s new binary mode. Note that this PR has a trivial bug that was fixed in our fork. + + +### Optional: increase spidev buffer size +This allows `ST7789.py` to update the LCD without performing multiple write operations because the default buffer size is 4096 bytes. The default can be changed via the `/boot/cmdline.txt` file. You will need to add `spidev.bufsiz=131072` to the end of this single lined file command. + +Example `cmdline.txt` contents: +``` +console=serial0,115200 console=tty1 root=PARTUUID=2fa4ba7e-02 rootfstype=ext4 elevator=deadline fsck.repair=yes rootwait modules-load=dwc2,g_ether spidev.bufsiz=131072 +``` ### Configure `systemd` to run SeedSigner at boot: -``` + +```bash sudo nano /etc/systemd/system/seedsigner.service ``` -Add the following contents to the text file that was created: -``` +Add the following contents to the text file that was created: +If you are not using the username pi, then replace `pi` in the service section below with your username. There are 3 lines to change. +```ini [Unit] Description=Seedsigner [Service] User=pi WorkingDirectory=/home/pi/seedsigner/src/ -ExecStart=/home/pi/.envs/seedsigner-env/bin/python3 main.py > /dev/null 2>&1 +ExecStart=/usr/bin/python3 main.py > /dev/null 2>&1 Restart=always [Install] @@ -199,46 +220,94 @@ WantedBy=multi-user.target _Note: For local dev you'll want to edit the `Restart=always` line to `Restart=no`. This way when your dev code crashes it won't keep trying to restart itself. Note that the UI "Reset" will no longer work when auto-restarts are disabled._ -_Note: Debugging output is completely wiped via routing the output to `/dev/null 2>&1`. When working in local dev, you're better off disabling the `systemd` SeedSigner service and just directly running the app so you can see all the debugging output live._ +_Note: Debugging output is completely wiped via routing the output to `/dev/null 2>&1`. When working in local dev, you'll `kill` the `systemd` SeedSigner service and just directly run the code on demand so you can see all the debugging output live._ Use `CTRL-X` and `y` to exit and save changes. Configure the service to start running (this will restart the seedsigner code automatically at startup and if it crashes): -``` +```bash sudo systemctl enable seedsigner.service ``` Now reboot the Raspberry Pi: -``` +```bash sudo reboot ``` After the Raspberry Pi reboots, you should see the SeedSigner splash screen and the SeedSigner menu subsequently appear on the LCD screen (note that it can take up to 60 seconds for the menu to appear). + +#### Optional: kill `systemd` SeedSigner process on login +If you're going to be testing new code on the device, you'll find yourself often needing to kill the SeedSigner instance that `systemd` automatically runs at startup. + +You can configure your `~/.profile` to find and kill the SeedSigner process when you ssh in. + +`nano ~/.profile` and add at the end: +```bash +# Find the SeedSigner process and kill it +kill $(ps aux | grep '[m]ain.py' | awk '{print $2}') +``` + + ### Further OS modifications Disable and remove the system's virtual memory / swap file with the commands: -``` + +```bash sudo apt remove dphys-swapfile -y sudo apt autoremove -y sudo rm /var/swap ``` -## Local testing and development -For those who will use the SeedSigner installation for testing/development, it can be helpful to change the system's host name so it doesn't potentially conflict with other Raspberry Pis that may already be present on your network. (For those who don't plan to use the installation for testing or development, you can skip this portion of the process.) To change the host name first edit the "hostname" with the command: +## Manually start the SeedSigner code +```bash +cd ~/seedsigner/src + +# You'll find the main.py file in that directory. Run it: +python main.py + +# To kill the process, use CTRL-C ``` + + +## Local testing and development + +### Run specific branches or PRs +The default branch is `dev`. If you want to run a specific release tag or a specific branch: +```bash +# release tag for v0.6.0: +git checkout 0.6.0 +``` + +And if you want to test a pull request (PR), for example PR #123: +```bash +git fetch origin pull/123/head:pr_123 +git checkout pr_123 +``` + +where `pr_123` is any name you want to give to the new branch in your local repo that will hold the PR. + + +### Change the host name +For those who will use the SeedSigner installation for testing/development, it can be helpful to change the system's host name so it doesn't potentially conflict with other Raspberry Pis that may already be present on your network. (For those who don't plan to use the installation for testing or development, you can skip this portion of the process.) To change the host name first edit the "hostname" with the command: + +```bash sudo nano /etc/hostname ``` -and change "raspberrypi" to "seedsigner" (or another name). Use `CTRL-X` and `y` to exit and save changes. You'll also need to edit the "hosts" file with the command: -``` + +and change "raspberrypi" to "seedsigner" (or another name). Use `CTRL-X` and `y` to exit and save changes. + +You'll also need to edit the "hosts" file with the command: +```bash sudo nano /etc/hosts ``` + and change "raspberrypi" to "seedsigner" (or the other name you previously chose). Use `CTRL-X` and `y` to exit and save changes. ### Set a static IP Your local machine that `ssh`s into the SeedSigner can sometimes get confused if you're connecting to different SeedSigners that are all identified as `pi@seedsigner.local`. In this case it helps to set a static ip and just `ssh` directly to that instead. First find your current `nameserver`: -``` +```bash sudo cat /etc/resolv.conf ``` @@ -260,21 +329,26 @@ static domain_name_servers=192.168.1.254 `CTRL-X` and `y` to save changes. After your next reboot, access this SeedSigner using its new static ip: -``` +```bash +# Use the static ip you set above: ssh pi@192.168.1.200 + +# But the hostname will still work, too: +ssh pi@seedsigner.local ``` ### More convenient `ssh` access: Power SeedSigner devs will find themselves connecting to a lot of different SeedSigners. This can cause headaches with `ssh`'s built-in protections; a different device that uses the same `ssh` credentials is normally a potential spoofing attack. But we're doing this to ourselves on purpose and so we can carve out exceptions. On your local machine, run `nano ~/.ssh/config` and add to the end: -``` +```conf host seedsigner.local StrictHostKeyChecking no UserKnownHostsFile /dev/null User pi LogLevel QUIET +# Set this to the static ip you set above: host 192.168.1.200 StrictHostKeyChecking no UserKnownHostsFile /dev/null @@ -293,7 +367,7 @@ The second entry does the same for a specific static ip; you'll want this if you You can also configure the SeedSigner so that you don't have to enter the `pi` password when you `ssh` in. run `ssh-copy-id` with the same values that you connect via `ssh`: -``` +```bash ssh-copy-id pi@seedsigner.local # or if you're connecting over static ip, something like: @@ -307,7 +381,7 @@ _Note: If you don't have any ssh keys on your local machine, you'll need to crea ## Disable wifi/Bluetooth when using other Raspi boards If you plan to use your installation on a Raspberry Pi that is not a Zero version 1.3, but rather on a Raspberry Pi that has WiFi and Bluetooth capabilities, it is a good idea to disable the following WiFi & Bluetooth, as well as other relevant services (assuming you are not creating this installation for testing/development purposes). Enter the followiing commands to disable WiFi, Bluetooth, & other relevant services: -``` +```bash sudo systemctl disable bluetooth.service sudo systemctl disable wpa_supplicant.service sudo systemctl disable dhcpcd.service @@ -316,12 +390,13 @@ sudo systemctl disable networking.service sudo systemctl disable dphys-swapfile.service sudo ifconfig wlan0 down ``` + Please note that if you are using WiFi to connect/interact with your Raspberry Pi, the last command will sever that connection. You can now safely power the Raspberry Pi off from the SeedSigner main menu. If you do not plan to use your installation for testing/development, it is also a good idea to disable WiFi and Bluetooth by editing the config.txt file found in the installation's "boot" partition. You can add the following text to the end of that file with any simple text editor (Windows: Notepad, Mac: TextEdit, Linux: nano): -``` +```ini dtoverlay=disable-bt dtoverlay=pi3-disable-wifi ``` diff --git a/docs/seed_qr/README.md b/docs/seed_qr/README.md index 3eba91de..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. @@ -70,7 +70,7 @@ It's important to note here that QR codes can encode data in a number of differe QR codes are typically used to encode a website url, in which case the "Alphanumeric" format has to be used (the encoded data can consist of upper- and lowercase letters, numbers, and certain allowed symbols). -If you have a long url like `https://ohnoihavealongurl.com` (29 characters), the chart shows that it would not fit in a 21x21 QR code; its max capacity is 25 alphanumeric chars. But it's within the 47-char capacity of the 29x29 size. +If you have a long url like `https://ohnoihavealongurl.com` (29 characters), the chart shows that it would not fit in a 21x21 QR code; its max capacity is 25 alphanumeric chars. But it's within the 47-char capacity of the 25x25 size. ### Bit efficiency matters Notice that the "Numeric" column has greater capacity. This is because when you have fewer possible characters to encode, it takes less data to specify each one. @@ -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. @@ -298,23 +298,20 @@ It's just the above process in reverse: 12. nuclear 1210 ``` -It would be much more difficult to manually recreate your seed from a CompactSeedQR. Tools like [zxing.org](https://zxing.org/w/decode.jspx) can help you get the binary data out as a hexidecimal string: +It would be much more difficult to manually recreate your seed from a CompactSeedQR. Tools like [zxing.org](https://zxing.org/w/decode.jspx) or [ZBar](https://zbar.sourceforge.net/) can help you get the binary data out as a hexidecimal string: -All 12-word CompactSeedQRs will start with `41 0` (that specifies the binary data format and says the length of the data is 16 bytes) and end with `0 ec` (unused byte and a half). - -All 24-word CompactSeedQRs will start with `42 0` (binary, data length is 32 bytes) and end with `0` (unused half a byte). - -The remaining hexidecimal data is just an alternate representation of the 128-bit entropy for your 12-word mnemonic (or 256 bits for a 24-word mnemonic). Various programming tools exist to convert from hex back into mnemonic form. +Note that for some QR decoders, zxing in particular, may return more data than the compact seed itself: the data type, the data length, and padding. For example, the zxing result from 12-word CompactSeedQRs in low error correction mode will start with `41 0` (that specifies the binary data format and says the length of the data is 16 bytes) and end with `0 ec` (unused byte and a half). Similarly, 24-word CompactSeedQRs will start with `42 0` (binary, data length is 32 bytes) and end with `0` (unused half a byte). +The ZBar library just returns the encoded data, without metadata or padding. ## Obfuscation Conversely, having limited support for reading binary QR codes and the complications described above are seen by some as an added security feature. Should someone steal your CompactSeedQR or take a photo of it, they'll have to be fairly savvy to know how to decode it. # 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. @@ -480,3 +477,72 @@ b'\n\xcb\xba\x00\x8d\x9b\xa0\x05\xf5\x99k@\xa3G\\\xd9' +--- + +## Test Vectors 7-9: Additional Compact SeedQR problem characters +Explicitly check Compact SeedQRs whose byte stream contains `\n`, `\r`, or `\r\n`: + +`\n`: +```bash +# 12-word seed: +dignity utility vacant shiver thought canoe feel multiply item youth actor coyote + +# Standard SeedQR digit stream: +049619221923158517990268067811630950204300210397 + +# CompactSeedQR bitstream: +00111110000111100000101111000001111000110001111000001110010000110001010100110100100010110111011011011111111011000000101010011000 + +# CompactSeedQR bytestream: +b'>\x1e\x0b\xc1\xe3\x1e\x0eC\x154\x8bv\xdf\xec\n\x98' +``` + + + + + +

CompactSeedQR
+ + +`\r`: +```bash +# 12-word seed: +corn voice scrap arrow original diamond trial property benefit choose junk lock + +# Standard SeedQR digit stream: +038719631547010112530489185713790169032209701051 + +# CompactSeedQR bitstream: +00110000011111101010111100000101100001100101100111001010011110100111101000001101011000110001010100100101000010011110010101000001 + +# CompactSeedQR bytestream: +b'0~\xaf\x05\x86Y\xcazz\rc\x15%\t\xe5A' +``` + + + + + +

CompactSeedQR
+ + +`\r\n`: +```bash +# 12-word seed: +vocal tray giggle tool duck letter category pattern train magnet excite swamp + +# Standard SeedQR digit stream: +196218530783182905421028028912901848107106301753 + +# CompactSeedQR bitstream: +11110101010111001111010110000111111100100101010000111101000000010000100100001101000010101110011100010000101111010011101101101101 + +# CompactSeedQR bytestream: +b'\xf5\\\xf5\x87\xf2T=\x01\t\r\n\xe7\x10\xbd;m' +``` + + + + + +

CompactSeedQR
diff --git a/docs/seed_qr/img/vector7_compact_12word.png b/docs/seed_qr/img/vector7_compact_12word.png new file mode 100644 index 00000000..afdd9d3a Binary files /dev/null and b/docs/seed_qr/img/vector7_compact_12word.png differ diff --git a/docs/seed_qr/img/vector8_compact_12word.png b/docs/seed_qr/img/vector8_compact_12word.png new file mode 100644 index 00000000..539b0ac2 Binary files /dev/null and b/docs/seed_qr/img/vector8_compact_12word.png differ diff --git a/docs/seed_qr/img/vector9_compact_12word.png b/docs/seed_qr/img/vector9_compact_12word.png new file mode 100644 index 00000000..213453f1 Binary files /dev/null and b/docs/seed_qr/img/vector9_compact_12word.png differ diff --git a/docs/seed_qr/printable_templates/12words_seedqr_template.pdf b/docs/seed_qr/printable_templates/12words_seedqr_template.pdf deleted file mode 100644 index 4c4cf14f..00000000 Binary files a/docs/seed_qr/printable_templates/12words_seedqr_template.pdf and /dev/null differ diff --git a/docs/seed_qr/printable_templates/21x21_A4_trading_card_2sided.pdf b/docs/seed_qr/printable_templates/21x21_A4_trading_card_2sided.pdf new file mode 100644 index 00000000..01946eea Binary files /dev/null and b/docs/seed_qr/printable_templates/21x21_A4_trading_card_2sided.pdf differ diff --git a/docs/seed_qr/printable_templates/21x21_letter_trading_card_2sided.pdf b/docs/seed_qr/printable_templates/21x21_letter_trading_card_2sided.pdf new file mode 100644 index 00000000..40e1d647 Binary files /dev/null and b/docs/seed_qr/printable_templates/21x21_letter_trading_card_2sided.pdf differ diff --git a/docs/seed_qr/printable_templates/24words_seedqr_template.pdf b/docs/seed_qr/printable_templates/24words_seedqr_template.pdf deleted file mode 100644 index e5ea1cc5..00000000 Binary files a/docs/seed_qr/printable_templates/24words_seedqr_template.pdf and /dev/null differ diff --git a/docs/seed_qr/printable_templates/25x25_A4_trading_card_2sided.pdf b/docs/seed_qr/printable_templates/25x25_A4_trading_card_2sided.pdf new file mode 100644 index 00000000..4340c517 Binary files /dev/null and b/docs/seed_qr/printable_templates/25x25_A4_trading_card_2sided.pdf differ diff --git a/docs/seed_qr/printable_templates/25x25_letter_trading_card_2sided.pdf b/docs/seed_qr/printable_templates/25x25_letter_trading_card_2sided.pdf new file mode 100644 index 00000000..60691b58 Binary files /dev/null and b/docs/seed_qr/printable_templates/25x25_letter_trading_card_2sided.pdf differ diff --git a/docs/seed_qr/printable_templates/29x29_A4_trading_card_2sided.pdf b/docs/seed_qr/printable_templates/29x29_A4_trading_card_2sided.pdf new file mode 100644 index 00000000..c84ffbdd Binary files /dev/null and b/docs/seed_qr/printable_templates/29x29_A4_trading_card_2sided.pdf differ diff --git a/docs/seed_qr/printable_templates/29x29_letter_trading_card_2sided.pdf b/docs/seed_qr/printable_templates/29x29_letter_trading_card_2sided.pdf new file mode 100644 index 00000000..7731f967 Binary files /dev/null and b/docs/seed_qr/printable_templates/29x29_letter_trading_card_2sided.pdf differ diff --git a/docs/seed_qr/printable_templates/Seed_QR_Card.pdf b/docs/seed_qr/printable_templates/Seed_QR_Card.pdf deleted file mode 100644 index c7df3fbd..00000000 Binary files a/docs/seed_qr/printable_templates/Seed_QR_Card.pdf and /dev/null differ diff --git a/docs/seed_qr/printable_templates/compact_seedqr/12words_compactseedqr_template.pdf b/docs/seed_qr/printable_templates/compact_seedqr/12words_compactseedqr_template.pdf deleted file mode 100644 index d5924541..00000000 Binary files a/docs/seed_qr/printable_templates/compact_seedqr/12words_compactseedqr_template.pdf and /dev/null differ diff --git a/docs/seed_qr/printable_templates/compact_seedqr/24words_compactseedqr_template.pdf b/docs/seed_qr/printable_templates/compact_seedqr/24words_compactseedqr_template.pdf deleted file mode 100644 index 0bf49c79..00000000 Binary files a/docs/seed_qr/printable_templates/compact_seedqr/24words_compactseedqr_template.pdf and /dev/null differ diff --git a/docs/seed_qr/printable_templates/dots_21x21.pdf b/docs/seed_qr/printable_templates/dots_21x21.pdf new file mode 100644 index 00000000..d5a38c59 Binary files /dev/null and b/docs/seed_qr/printable_templates/dots_21x21.pdf differ diff --git a/docs/seed_qr/printable_templates/dots_25x25.pdf b/docs/seed_qr/printable_templates/dots_25x25.pdf new file mode 100644 index 00000000..625f5fc7 Binary files /dev/null and b/docs/seed_qr/printable_templates/dots_25x25.pdf differ diff --git a/docs/seed_qr/printable_templates/dots_29x29.pdf b/docs/seed_qr/printable_templates/dots_29x29.pdf new file mode 100644 index 00000000..a43fa0c8 Binary files /dev/null and b/docs/seed_qr/printable_templates/dots_29x29.pdf differ diff --git a/docs/seed_qr/printable_templates/grid_21x21.pdf b/docs/seed_qr/printable_templates/grid_21x21.pdf new file mode 100644 index 00000000..ebef81b4 Binary files /dev/null and b/docs/seed_qr/printable_templates/grid_21x21.pdf differ diff --git a/docs/seed_qr/printable_templates/grid_25x25.pdf b/docs/seed_qr/printable_templates/grid_25x25.pdf new file mode 100644 index 00000000..bfdaf9ef Binary files /dev/null and b/docs/seed_qr/printable_templates/grid_25x25.pdf differ diff --git a/docs/seed_qr/printable_templates/grid_29x29.pdf b/docs/seed_qr/printable_templates/grid_29x29.pdf new file mode 100644 index 00000000..59177397 Binary files /dev/null and b/docs/seed_qr/printable_templates/grid_29x29.pdf differ diff --git a/docs/seed_qr/printable_templates/grid_wfingerprint_21x21.pdf b/docs/seed_qr/printable_templates/grid_wfingerprint_21x21.pdf new file mode 100644 index 00000000..062dc80e Binary files /dev/null and b/docs/seed_qr/printable_templates/grid_wfingerprint_21x21.pdf differ diff --git a/docs/seed_qr/printable_templates/grid_wfingerprint_21x21_4-up.pdf b/docs/seed_qr/printable_templates/grid_wfingerprint_21x21_4-up.pdf new file mode 100644 index 00000000..0036b76e Binary files /dev/null and b/docs/seed_qr/printable_templates/grid_wfingerprint_21x21_4-up.pdf differ diff --git a/docs/seed_qr/printable_templates/mnemonic_12words_wfingerprint.pdf b/docs/seed_qr/printable_templates/mnemonic_12words_wfingerprint.pdf new file mode 100644 index 00000000..4cb9f2b8 Binary files /dev/null and b/docs/seed_qr/printable_templates/mnemonic_12words_wfingerprint.pdf differ diff --git a/docs/seed_qr/printable_templates/mnemonic_12words_wfingerprint_4up.pdf b/docs/seed_qr/printable_templates/mnemonic_12words_wfingerprint_4up.pdf new file mode 100644 index 00000000..4bc041ab Binary files /dev/null and b/docs/seed_qr/printable_templates/mnemonic_12words_wfingerprint_4up.pdf differ diff --git a/docs/seed_qr/printable_templates/mnemonic_24word_wfingerprint.pdf b/docs/seed_qr/printable_templates/mnemonic_24word_wfingerprint.pdf new file mode 100644 index 00000000..bc6ad3d8 Binary files /dev/null and b/docs/seed_qr/printable_templates/mnemonic_24word_wfingerprint.pdf differ diff --git a/docs/seed_qr/printable_templates/mnemonic_24word_wfingerprint_4-up.pdf b/docs/seed_qr/printable_templates/mnemonic_24word_wfingerprint_4-up.pdf new file mode 100644 index 00000000..c8f9d5ec Binary files /dev/null and b/docs/seed_qr/printable_templates/mnemonic_24word_wfingerprint_4-up.pdf differ diff --git a/docs/seed_qr/printable_templates/template_generator.py b/docs/seed_qr/printable_templates/template_generator.py deleted file mode 100644 index f0ed2f27..00000000 --- a/docs/seed_qr/printable_templates/template_generator.py +++ /dev/null @@ -1,294 +0,0 @@ -import math - -def generate_qr_template(qr_size, num_words=24, block_size=5, show_timing_marks=False): - if num_words == 24: - word_cols = 2 - else: - word_cols = 1 - - html = """ - - - - - - - - """ - - html += """ - - - -
- """ - for i in range(1, num_words + 1): - if i == 13: - html += """ - - """ - html += f"""
- {i}: ____________________________ -
- """ - html += "
" - - y_names = "A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,X,Y,Z" - - html += """""" - html += f"""""" - for j in range(0, math.ceil(qr_size/block_size)): - html += f"""""" - html += "" - - for i in range(0, qr_size): - html += """\n""" - if i % block_size == 0: - html += f"""""" - for j in range(0, qr_size): - html += f"""""" - html += """\n""" - html += "
{j + 1}
{y_names.split(",")[int(i / block_size)]}·
" - - html += " - - """ - - return html - - - -if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser( - description=""" - Generates a blank QR code template at 21x21, 25x25, or 29x29. - - ex: python3 qr_code_template.py 25 12 - ex: python3 qr_code_template.py 29 24 - - Optionally change the block size divider guides: - - python3 qr_code_template.py 29 24 --block_size 6 - """, - formatter_class=argparse.RawTextHelpFormatter - ) - - # Required positional arguments - parser.add_argument('qr_size', - type=int, - choices=[21, 25, 29], - help="QR code size. 21 for 21x21, 25 for 25x25, 29 for 29x29") - parser.add_argument('num_words', - type=int, - choices=[12, 24], - help="Number of words in the mnemonic seed. 12 or 24") - - # Add optional arguments - parser.add_argument('-b', '--block_size', - type=int, - default=5, - help="Size of the manual entry zoom blocks") - parser.add_argument('-t', '--timing_marks', - action='store_true', - default=False, - help="Show timing marks (the dashed blocks linking the three large registration boxes") - args = parser.parse_args() - - html = generate_qr_template(qr_size=args.qr_size, num_words=args.num_words, block_size=args.block_size, show_timing_marks=args.timing_marks) - file = open(f"{args.num_words}words_seedqr_template-{args.qr_size}x{args.qr_size}.html", "w") - file.write(html) - file.close() - diff --git a/docs/seed_qr/printable_templates/21x21_trading_card.pdf b/docs/seed_qr/printable_templates/trading_card_21x21_w12words.pdf similarity index 100% rename from docs/seed_qr/printable_templates/21x21_trading_card.pdf rename to docs/seed_qr/printable_templates/trading_card_21x21_w12words.pdf diff --git a/docs/seed_qr/printable_templates/25x25_trading_card.pdf b/docs/seed_qr/printable_templates/trading_card_25x25_w24words.pdf similarity index 100% rename from docs/seed_qr/printable_templates/25x25_trading_card.pdf rename to docs/seed_qr/printable_templates/trading_card_25x25_w24words.pdf diff --git a/docs/seed_qr/printable_templates/29x29_trading_card.pdf b/docs/seed_qr/printable_templates/trading_card_29x29_w24words.pdf similarity index 100% rename from docs/seed_qr/printable_templates/29x29_trading_card.pdf rename to docs/seed_qr/printable_templates/trading_card_29x29_w24words.pdf diff --git a/docs/seed_qr/printable_templates/xpub_wfingerprint.pdf b/docs/seed_qr/printable_templates/xpub_wfingerprint.pdf new file mode 100644 index 00000000..28688c97 Binary files /dev/null and b/docs/seed_qr/printable_templates/xpub_wfingerprint.pdf differ diff --git a/enclosures/look_screws_pill/Bottom.stl b/enclosures/look_screws_pill/Bottom.stl new file mode 100644 index 00000000..4dd7af9b Binary files /dev/null and b/enclosures/look_screws_pill/Bottom.stl differ diff --git a/enclosures/look_screws_pill/ButtonsConnected.stl b/enclosures/look_screws_pill/ButtonsConnected.stl new file mode 100644 index 00000000..2cb46fcf Binary files /dev/null and b/enclosures/look_screws_pill/ButtonsConnected.stl differ diff --git a/enclosures/look_screws_pill/README.md b/enclosures/look_screws_pill/README.md new file mode 100644 index 00000000..be49ccfc --- /dev/null +++ b/enclosures/look_screws_pill/README.md @@ -0,0 +1,15 @@ +Motivation for this design: Faceplate screws could look cool. Judge for yourself! + +It's work that's built on top of giants (@gobrrrme and @blackcoffee). + +The main chassis has the properties of a battle-tested enclosure called SimplePill by @blackcoffee and connected buttons are a slightly reworked design done by @gobrrrme. + +What distinguishes this design are the visible screws on top of the enclosure and a presspad. + +This design requires: +* 4x 10mm M2.5 risers +* 8x 10mm M2.5 screws + +The buttons and presspad HAVE to be printed out of TPU filament. The Top and Bottom are best from PLA but PETG will work just as well. The presspad has a slightly smaller cavity for the stubby nub of the waveshare hat. DO NOT PANIC! It's done on purpose to create friction for the pad to hold on the nub. + +![image](https://github.com/surfac3/seedsigner/assets/89400663/082c3c22-6bbd-402c-806e-98a32700621b) diff --git a/enclosures/look_screws_pill/Top.stl b/enclosures/look_screws_pill/Top.stl new file mode 100644 index 00000000..06e9308c Binary files /dev/null and b/enclosures/look_screws_pill/Top.stl differ diff --git a/enclosures/look_screws_pill/pressPad.stl b/enclosures/look_screws_pill/pressPad.stl new file mode 100644 index 00000000..2dbf0adb Binary files /dev/null and b/enclosures/look_screws_pill/pressPad.stl differ diff --git a/enclosures/open_pill/README.md b/enclosures/open_pill/README.md new file mode 100644 index 00000000..a7245ca3 --- /dev/null +++ b/enclosures/open_pill/README.md @@ -0,0 +1,27 @@ +## The Open Pill Enclosure + +The "Open Pill" was the second officially released SeedSigner enclosure. Given some of the challenges associated with the "Orange Pill", the Open Pill was designed for quick, inexpensive, and simple deployment. No secondary hardware components are required for assembly and the design consists of a single printed part that can be produced with even basic 3D printers. This simpler design was intended to put the focus back on the project's software, which was improving by leaps and bounds when the enclosure was released. + + + +### Characterisics: +- Supported Camera? Legacy RPi Camera (w/ gold Pi Zero Cable) +- Supported HAT? Waveshare 240x240 pixel LCD display + controls +- Recommended printing process? FDM +- Recommended printing materials? PLA +- Secondary Hardware Required? No +- Data-enabled USB Port accessible? Yes +- Mini-HDMI port accessible? Yes +- Removeable Memory Card? Yes +- Comfortable controls? No (see note below) + +### Assembly Demonstration: +https://www.youtube.com/watch?v=gXPFJygZobE + + +### Comfortable Controls Optional Upgrade: +The bare joystick on an open pill or open pill mini design can be uncomfortable for some thumbs. Twitter user @Vulcan21com developed a DIY solution to add comfort to the exposed joystick using a M2.5 knurled nut standardized under DIN 466, widely available at many hardware stores. The joystick itself has no threading, but it is possible to screw the M2.5 nut onto the joystick with a bit of precision and patience. While screwing it on for the first time it will cut a light thread into the plastic. Be gentle as to not transfer too much torque into the joystick. The result will look like this: + + + + diff --git a/enclosures/open_pill_mini/Main_Chassis.stl b/enclosures/open_pill_mini/Main_Chassis.stl new file mode 100644 index 00000000..805dbaea Binary files /dev/null and b/enclosures/open_pill_mini/Main_Chassis.stl differ diff --git a/enclosures/open_pill_mini/README.md b/enclosures/open_pill_mini/README.md new file mode 100644 index 00000000..f399d496 --- /dev/null +++ b/enclosures/open_pill_mini/README.md @@ -0,0 +1,26 @@ +## The Open Pill Mini Enclosure + +The "Open Pill Mini" is simply a smaller version of the original "Open Pill" that is designed to incorporate a more compact camera specifically designed for the Raspberry Pi Zero -- this enclosure is just about as small as a SeedSigner can conceivably get. It is pictured below, alongside the original Open Pill enclosure. + + + +### Characterisics: +- Supported Camera? "ZeroCam" designed specifically for RPi Zero +- Supported HAT? Waveshare 240x240 pixel LCD display + controls +- Recommended printing process? FDM +- Recommended printing materials? PLA +- Secondary Hardware Required? No +- Data-enabled USB Port accessible? Yes +- Mini-HDMI port accessible? Yes +- Removeable Memory Card? Yes +- Comfortable controls? No (see note below) + +### Assembly Demonstration: +(assembly is very similar to the original Open Pill assembly, depicted below) +https://www.youtube.com/watch?v=aIIc2DiZYcI + +### Comfortable Controls Optional Upgrade: +The bare joystick on an open pill or open pill mini design can be uncomfortable for some thumbs. Twitter user @Vulcan21com developed a DIY solution to add comfort to the exposed joystick using a M2.5 knurled nut standardized under DIN 466, widely available at many hardware stores. The joystick itself has no threading, but it is possible to screw the M2.5 nut onto the joystick with a bit of precision and patience. While screwing it on for the first time it will cut a light thread into the plastic. Be gentle as to not transfer too much torque into the joystick. The result will look like this: + + +(image displayed is Open Pill, not Open Pill Mini) diff --git a/enclosures/open_pill_mini_w_coverplate/Buttons.stl b/enclosures/open_pill_mini_w_coverplate/Buttons.stl new file mode 100644 index 00000000..f6e89c44 Binary files /dev/null and b/enclosures/open_pill_mini_w_coverplate/Buttons.stl differ diff --git a/enclosures/open_pill_mini_w_coverplate/Lid.stl b/enclosures/open_pill_mini_w_coverplate/Lid.stl new file mode 100644 index 00000000..356ef499 Binary files /dev/null and b/enclosures/open_pill_mini_w_coverplate/Lid.stl differ diff --git a/enclosures/open_pill_mini_w_coverplate/Main_Chassis.stl b/enclosures/open_pill_mini_w_coverplate/Main_Chassis.stl new file mode 100644 index 00000000..7f31bfd6 Binary files /dev/null and b/enclosures/open_pill_mini_w_coverplate/Main_Chassis.stl differ diff --git a/enclosures/open_pill_mini_w_coverplate/README.md b/enclosures/open_pill_mini_w_coverplate/README.md new file mode 100644 index 00000000..5bd9d62f --- /dev/null +++ b/enclosures/open_pill_mini_w_coverplate/README.md @@ -0,0 +1,19 @@ +## The Open Pill Mini w/ Coverplate Enclosure + +The "Open Pill Mini w/ Coverplate" was designed as an attempt to incorporate the best of the "Orange Pill" and the "Open Pill" enclosures into a single design. It is fully enclosed for improved aesthetics and protection of electronic components, yet is compact and can be manufactured with a simple FDM printer, also not requiring any additional hardware components. A first for this enclosure was preventing access to the SeedSigner's data-enabled USB port as an additional security assurance for users. + + + +### Characterisics: +- Supported Camera? "ZeroCam" designed specifically for RPi Zero +- Supported HAT? Waveshare 240x240 pixel LCD display + controls +- Recommended printing process? FDM for enclosure & controls +- Recommended printing materials? PLA for enclosure, TPU for controls +- Secondary Hardware Required? No +- Data-enabled USB Port accessible? No +- Mini-HDMI port accessible? No +- Removeable Memory Card? Yes +- Comfortable controls? Yes + +### Assembly Demonstration: +https://www.youtube.com/watch?v=6-5cDneXoWs diff --git a/enclosures/open_pill_mini_w_coverplate/Thumbstick_PLA.stl b/enclosures/open_pill_mini_w_coverplate/Thumbstick_PLA.stl new file mode 100644 index 00000000..c22c02c4 Binary files /dev/null and b/enclosures/open_pill_mini_w_coverplate/Thumbstick_PLA.stl differ diff --git a/enclosures/open_pill_mini_w_coverplate/Thumbstick_TPU.stl b/enclosures/open_pill_mini_w_coverplate/Thumbstick_TPU.stl new file mode 100644 index 00000000..0de57906 Binary files /dev/null and b/enclosures/open_pill_mini_w_coverplate/Thumbstick_TPU.stl differ diff --git a/enclosures/orange_pill/README.md b/enclosures/orange_pill/README.md new file mode 100644 index 00000000..a3348aae --- /dev/null +++ b/enclosures/orange_pill/README.md @@ -0,0 +1,24 @@ +## The Orange Pill Enclosure + +The "Orange Pill" was the first SeedSigner enclosure. It's eye-catching design is the enclosure most commonly associated with the SeedSigner project for many. While aesthetically appealing and fun to showcase to others, the thumbstick topper's sub-optimal design, the need for secondary hardware components, and other drawbacks make this enclosure a less desirable choice for every day SeedSigner use. + + + +### Characterisics: +- Supported Camera? Legacy RPi Camera (w/ gold Pi Zero Cable) +- Supported HAT? Waveshare 240x240 pixel LCD display + controls +- Recommended printing process? FDM for enclosure, SLA for controls +- Recommended printing materials? PLA for enclosure, resin for controls +- Secondary Hardware Required? Yes +- Data-enabled USB Port accessible? Yes +- Mini-HDMI port accessible? Yes +- Removeable Memory Card? Requires partial disassembly +- Comfortable controls? Yes (but thumbstick can be clumsy) + +### Secondary Hardware Required: +- Four (4): 10mm M2.5 F-F spacers +- Four (4): 6mm M2.5 screws +- Four (4): 12mm M2.5 screws + +### Assembly Demonstration: +https://www.youtube.com/watch?v=aIIc2DiZYcI 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..b90c5acd --- /dev/null +++ b/enclosures/pushcase/README.md @@ -0,0 +1,120 @@ +# The Push Case + + + +Designer: @kayth21 + +## 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). 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/enclosures/rugged_pill/Multimaterial/RuggedPIll_Bottom_MMU.3mf b/enclosures/rugged_pill/Multimaterial/RuggedPIll_Bottom_MMU.3mf new file mode 100644 index 00000000..ca393c01 Binary files /dev/null and b/enclosures/rugged_pill/Multimaterial/RuggedPIll_Bottom_MMU.3mf differ diff --git a/enclosures/rugged_pill/Multimaterial/RuggedPill_Top_MMU.3mf b/enclosures/rugged_pill/Multimaterial/RuggedPill_Top_MMU.3mf new file mode 100644 index 00000000..95cb6b1a Binary files /dev/null and b/enclosures/rugged_pill/Multimaterial/RuggedPill_Top_MMU.3mf differ diff --git a/enclosures/rugged_pill/README.md b/enclosures/rugged_pill/README.md new file mode 100644 index 00000000..3e6caad0 --- /dev/null +++ b/enclosures/rugged_pill/README.md @@ -0,0 +1,34 @@ +## The Rugged Pill Enclosure + + + +- The Rugged Pill is an enclosure designed for building a SeedSigner without the need for screws or spacers. +- This enclosure requires use of the so called "zerocam", which allows for a more compact form factor. +- The enclosure offers a very comfortable handling experience. +- The included controls can be printed from PLA, but TPU is recommended. +- Recommended shore hardness is 95A, but any TPU will do. +- Separate files are available for single and multicolor printing. +- Top and bottom part snap fit together. +- SD-card removable +- Data port is closed. (Use the separate file labeled 4_SeedHammer) + + +### File overwiev + +#### Single color: +- RuggedPill_Top.stl[^1] +- RuggedPill_Bottom.stl[^1] +- RuggedPillBottom_4SeedHammer.stl[^1] + +- RuggedPill_Buttons.stl[^2] +- RuggedPill_Thumbstick.stl[^2] +- Rugged_Pill_Trackball.stl[^2] + +#### Multimaterial: +- RuggedPill_Top_MMU.3mf +- RuggedPill_Bottom_MMU.3mf + +You only need one set of buttons and a Thumbstick or Trackball, print both since they use minmal material. Test which one you like best and use that. + +[^1]: (Recommended Materials: PLA, PETG) +[^2]: (Recommended Materials: TPU) diff --git a/enclosures/rugged_pill/RuggedPillBottom_4SeedHammer.stl b/enclosures/rugged_pill/RuggedPillBottom_4SeedHammer.stl new file mode 100644 index 00000000..04ac0813 Binary files /dev/null and b/enclosures/rugged_pill/RuggedPillBottom_4SeedHammer.stl differ diff --git a/enclosures/rugged_pill/RuggedPill_Bottom.stl b/enclosures/rugged_pill/RuggedPill_Bottom.stl new file mode 100644 index 00000000..c8f0ba78 Binary files /dev/null and b/enclosures/rugged_pill/RuggedPill_Bottom.stl differ diff --git a/enclosures/rugged_pill/RuggedPill_Buttons.stl b/enclosures/rugged_pill/RuggedPill_Buttons.stl new file mode 100644 index 00000000..d8928bf2 Binary files /dev/null and b/enclosures/rugged_pill/RuggedPill_Buttons.stl differ diff --git a/enclosures/rugged_pill/RuggedPill_Thumbstick.stl b/enclosures/rugged_pill/RuggedPill_Thumbstick.stl new file mode 100644 index 00000000..20367d54 Binary files /dev/null and b/enclosures/rugged_pill/RuggedPill_Thumbstick.stl differ diff --git a/enclosures/rugged_pill/RuggedPill_Top.stl b/enclosures/rugged_pill/RuggedPill_Top.stl new file mode 100644 index 00000000..0a871291 Binary files /dev/null and b/enclosures/rugged_pill/RuggedPill_Top.stl differ diff --git a/enclosures/rugged_pill/RuggedPill_Trackball.stl b/enclosures/rugged_pill/RuggedPill_Trackball.stl new file mode 100644 index 00000000..ccfbc1be Binary files /dev/null and b/enclosures/rugged_pill/RuggedPill_Trackball.stl differ diff --git a/enclosures/simple_pill/README.md b/enclosures/simple_pill/README.md new file mode 100644 index 00000000..8bbc83af --- /dev/null +++ b/enclosures/simple_pill/README.md @@ -0,0 +1,25 @@ +## The Simple Pill Enclosure + +The "Simple Pill" is a community-contributed design by @blackcoffeebtc. The most significant innovation of this design was the adaptation of a DPAD-stype thumbstick topper for a SeedSigner enclosure, offering a much more comfortable and responsive control experience. The Simple Pill was also the first enclosure to allow for easy access to the device's memory card. + + + +### Characterisics: +- Supported Camera? Legacy RPi Camera (w/ gold Pi Zero Cable) +- Supported HAT? Waveshare 240x240 pixel LCD display + controls +- Recommended printing process? FDM for enclosure, SLA for controls +- Recommended printing materials? PLA for enclosure, resin for controls +- Secondary Hardware Required? Yes +- Data-enabled USB Port accessible? Yes +- Mini-HDMI port accessible? Yes +- Removeable Memory Card? Yes +- Comfortable controls? Yes + +### Secondary Hardware Required: +- Four (4): 10mm M2.5 F-F spacers +- Four (4): 6mm M2.5 screws +- Four (4): 12mm M2.5 screws + +### Assembly Demonstration: +(assembly is similar to that of the original Orange Pill) +https://www.youtube.com/watch?v=aIIc2DiZYcI diff --git a/enclosures/simple_pill/protective_case/sp_protective_case_lower.stl b/enclosures/simple_pill/protective_case/sp_protective_case_lower.stl new file mode 100644 index 00000000..1fc2e277 Binary files /dev/null and b/enclosures/simple_pill/protective_case/sp_protective_case_lower.stl differ diff --git a/enclosures/simple_pill/protective_case/sp_protective_case_upper.stl b/enclosures/simple_pill/protective_case/sp_protective_case_upper.stl new file mode 100644 index 00000000..8d3e7f02 Binary files /dev/null and b/enclosures/simple_pill/protective_case/sp_protective_case_upper.stl differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..25345e0d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,70 @@ +[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" + +[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"] +log_level = "DEBUG" + +[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-raspi.txt b/requirements-raspi.txt new file mode 100644 index 00000000..d86e93d2 --- /dev/null +++ b/requirements-raspi.txt @@ -0,0 +1,5 @@ +picamera==1.13 +# numpy = transitive picamera dependency +numpy==1.25.2 +RPi.GPIO==0.7.0 +spidev==3.5 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5a58303a..89a1a589 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,5 @@ -embit @ git+https://github.com/seedsigner/embit.git@03cba13e0add64b241972c67e2aa970cb2e3a5d2 -numpy==1.21.1 -picamera==1.13 -Pillow==8.2.0 --e git+https://github.com/seedsigner/pyzbar.git@c3c237821c6a20b17953efe59b90df0b514a1c03#egg=pyzbar +embit==0.8.0 +Pillow==10.0.1 +pyzbar @ git+https://github.com/seedsigner/pyzbar.git@c3c237821c6a20b17953efe59b90df0b514a1c03 qrcode==7.3.1 -RPi.GPIO==0.7.0 -six==1.16.0 -spidev==3.5 -urtypes @ git+https://github.com/jreesun/urtypes.git@e0d0db277ec2339650343eaf7b220fffb9233241 +urtypes @ git+https://github.com/selfcustody/urtypes.git@7fb280eab3b3563dfc57d2733b0bf5cbc0a96a6a diff --git a/setup.py b/setup.py deleted file mode 100644 index cc8d8fb3..00000000 --- a/setup.py +++ /dev/null @@ -1,26 +0,0 @@ -import setuptools - -with open("README.md", "r", encoding="utf-8") as fh: - long_description = fh.read() - -setuptools.setup( - name="seedsigner", - version="0.5.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"), - python_requires=">=3.6", -) \ No newline at end of file diff --git a/src/main.py b/src/main.py old mode 100644 new mode 100755 index 740faf30..c6888267 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,53 @@ +#!/usr/bin/env python + +import argparse +import logging +import sys + from seedsigner.controller import Controller -# Get the one and only Controller instance and start our main loop -Controller.get_instance().start() +logger = logging.getLogger(__name__) + +DEFAULT_MODULE_LOG_LEVELS = { + "PIL": logging.WARNING, + # "seedsigner.gui.toast": logging.DEBUG, # example of more specific submodule logging config +} + + +def main(sys_argv=None): + parser = argparse.ArgumentParser() + parser.add_argument( + "-l", + "--loglevel", + choices=list((logging._nameToLevel.keys())), + default="INFO", + type=str, + help=( + "Set the log level (default: %(default)s), WARNING: changing the log level " + "to something more verbose than %(default)s may result in unwanted data " + "being written to stderr" + ), + ) + + args = parser.parse_args(sys_argv) + + root_logger = logging.getLogger() + root_logger.setLevel(logging.getLevelName(args.loglevel)) + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setFormatter( + logging.Formatter("%(asctime)s %(levelname)8s [%(name)s %(funcName)s (%(lineno)d)]: %(message)s") + ) + root_logger.addHandler(console_handler) + + # Set log levels for specific modules + for module, level in DEFAULT_MODULE_LOG_LEVELS.items(): + logging.getLogger(module).setLevel(level) + + logger.info(f"Starting SeedSigner with: {args.__dict__}") + + # Get the one and only Controller instance and start our main loop + Controller.get_instance().start() + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 44b06e69..5ac33d3a 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -1,24 +1,27 @@ import logging +import time import traceback from embit.descriptor import Descriptor from embit.psbt import PSBT from PIL.Image import Image -from typing import List -from seedsigner.gui.renderer import Renderer -from seedsigner.hardware.buttons import HardwareButtons +from seedsigner.gui.toast import BaseToastOverlayManagerThread +from seedsigner.models.psbt_parser import PSBTParser +from seedsigner.models.seed import Seed +from seedsigner.models.seed_storage import SeedStorage +from seedsigner.models.settings import Settings +from seedsigner.models.singleton import Singleton +from seedsigner.models.threads import BaseThread from seedsigner.views.screensaver import ScreensaverScreen -from seedsigner.views.view import Destination, NotYetImplementedView, UnhandledExceptionView - -from .models import Seed, SeedStorage, Settings, Singleton, PSBTParser +from seedsigner.views.view import Destination logger = logging.getLogger(__name__) -class BackStack(List[Destination]): +class BackStack(list[Destination]): def __repr__(self): if len(self) == 0: return "[]" @@ -30,6 +33,54 @@ class BackStack(List[Destination]): +class StopFlowBasedTest(Exception): + """ + This is a special exception that is only raised by the test suite to stop the + Controller's main loop. It should not be raised by any other code. + """ + pass + + + +class FlowBasedTestException(Exception): + """ + This is a special exception that is only raised by the test suite. + It should not be raised by any other code. + """ + pass + + + +class BackgroundImportThread(BaseThread): + def run(self): + from importlib import import_module + + # import seedsigner.hardware.buttons # slowly imports GPIO along the way + + def time_import(module_name): + last = time.time() + import_module(module_name) + # print(time.time() - last, module_name) + + time_import('embit') + time_import('seedsigner.helpers.embit_utils') + + # Do costly initializations + time_import('seedsigner.models.seed_storage') + from seedsigner.models.seed_storage import SeedStorage + Controller.get_instance()._storage = SeedStorage() + + # Get MainMenuView ready to respond quickly + time_import('seedsigner.views.scan_views') + + time_import('seedsigner.views.seed_views') + + time_import('seedsigner.views.tools_views') + + time_import('seedsigner.views.settings_views') + + + class Controller(Singleton): """ The Controller is a globally available singleton that maintains SeedSigner state. @@ -48,14 +99,12 @@ class Controller(Singleton): rather than at the top in order avoid circular imports. """ - VERSION = "0.5.1" + VERSION = "0.8.0" # Declare class member vars with type hints to enable richer IDE support throughout # the code. - buttons: HardwareButtons = None - storage: SeedStorage = None + _storage: SeedStorage = None # TODO: Rename "storage" to something more indicative of its temp, in-memory state settings: Settings = None - renderer: Renderer = None # TODO: Refactor these flow-related attrs that survive across multiple Screens. # TODO: Should all in-memory flow-related attrs get wiped on MainMenuView? @@ -67,8 +116,12 @@ class Controller(Singleton): multisig_wallet_descriptor: Descriptor = None - image_entropy_preview_frames: List[Image] = None + image_entropy_preview_frames: list[Image] = None image_entropy_final_image: Image = None + + address_explorer_data: dict = None + + sign_message_data: dict = None # TODO: end refactor section # Destination placeholder for when we need to jump out to a side flow but intend to @@ -77,10 +130,13 @@ class Controller(Singleton): FLOW__PSBT = "psbt" FLOW__VERIFY_MULTISIG_ADDR = "multisig_addr" FLOW__VERIFY_SINGLESIG_ADDR = "singlesig_addr" + FLOW__ADDRESS_EXPLORER = "address_explorer" + FLOW__SIGN_MESSAGE = "sign_message" resume_main_flow: str = None back_stack: BackStack = None screensaver: ScreensaverScreen = None + toast_notification_thread: BaseToastOverlayManagerThread = None @classmethod @@ -105,6 +161,9 @@ class Controller(Singleton): each time you try to re-initialize a Controller. """ + from seedsigner.gui.renderer import Renderer + from seedsigner.hardware.microsd import MicroSD + # Must be called before the first get_instance() call if cls._instance: raise Exception("Instance already configured") @@ -113,16 +172,11 @@ class Controller(Singleton): controller = cls.__new__(cls) cls._instance = controller - # Input Buttons - if disable_hardware: - controller.buttons = None - else: - controller.buttons = HardwareButtons.get_instance() - # models - # TODO: Rename "storage" to something more indicative of its temp, in-memory state - controller.storage = SeedStorage() controller.settings = Settings.get_instance() + + controller.microsd = MicroSD.get_instance() + controller.microsd.start_detection() # Store one working psbt in memory controller.psbt = None @@ -131,13 +185,14 @@ class Controller(Singleton): # Configure the Renderer Renderer.configure_instance() - controller.screensaver = ScreensaverScreen(controller.buttons) - controller.back_stack = BackStack() # Other behavior constants - controller.screensaver_activation_ms = 120 * 1000 + controller.screensaver_activation_ms = 2 * 60 * 1000 # two minutes + background_import_thread = BackgroundImportThread() + background_import_thread.start() + return cls._instance @@ -145,6 +200,15 @@ class Controller(Singleton): def camera(self): from .hardware.camera import Camera return Camera.get_instance() + + + @property + def storage(self): + while not self._storage: + # Wait for the BackgroundImportThread to finish initializing the storage. + # This is a rare timing issue that likely only occurs in the test suite. + time.sleep(0.001) + return self._storage def get_seed(self, seed_num: int) -> Seed: @@ -162,7 +226,6 @@ class Controller(Singleton): def pop_prev_from_back_stack(self): - from .views import Destination if len(self.back_stack) > 0: # Pop the top View (which is the current View_cls) self.back_stack.pop() @@ -177,12 +240,18 @@ class Controller(Singleton): self.back_stack = BackStack() - def start(self) -> None: - from .views import MainMenuView, BackStackView - from .views.screensaver import OpeningSplashScreen + def start(self, initial_destination: Destination = None) -> None: + """ + The main loop of the application. - opening_splash = OpeningSplashScreen() - opening_splash.start() + * initial_destination: The first View to run. If None, the MainMenuView is + used. Only used by the test suite. + """ + from seedsigner.views import MainMenuView, BackStackView + from seedsigner.views.screensaver import OpeningSplashScreen + from seedsigner.gui.toast import RemoveSDCardToastManagerThread + + OpeningSplashScreen().start() """ Class references can be stored as variables in python! @@ -195,11 +264,11 @@ class Controller(Singleton): Example: class MyView(View) def run(self, some_arg, other_arg): - print(other_arg) + logger.info(other_arg) - class OtherView(): + class OtherView(View): def run(self): - return (MyView, {"some_arg": 1, "other_arg": "hello"}) + return (MyView, dict(some_arg=1, other_arg="hello")) When `OtherView` is instantiated and run, we capture its return values: @@ -210,7 +279,14 @@ class Controller(Singleton): View_cls(**init_args).run() """ try: - next_destination = Destination(MainMenuView) + if initial_destination: + next_destination = initial_destination + else: + next_destination = Destination(MainMenuView) + + # Set up our one-time toast notification tip to remove the SD card + self.activate_toast(RemoveSDCardToastManagerThread()) + while True: # Destination(None) is a special case; render the Home screen if next_destination.View_cls is None: @@ -220,30 +296,48 @@ class Controller(Singleton): # Home always wipes the back_stack self.clear_back_stack() - # Clear other temp vars + # Home always wipes the back_stack/state of temp vars self.resume_main_flow = None self.multisig_wallet_descriptor = None self.unverified_address = None - + self.address_explorer_data = None + self.psbt = None + self.psbt_parser = None + self.psbt_seed = None - print(f"back_stack: {self.back_stack}") + logger.info(f"\nback_stack: {self.back_stack}") try: - print(f"Executing {next_destination}") + # Instantiate the View class and run it + logger.info(f"Executing {next_destination}") next_destination = next_destination.run() + + except StopFlowBasedTest: + # This is a special exception that is only raised by the test suite + # to stop the Controller loop and exit the test. + return + + except FlowBasedTestException as e: + # This is a special exception that is only raised by the test suite. + # Re-raise so the test suite can handle it. + raise e + except Exception as e: # Display user-friendly error screen w/debugging info + import traceback + traceback.print_exc() next_destination = self.handle_exception(e) if not next_destination: # Should only happen during dev when you hit an unimplemented option + from seedsigner.views.view import NotYetImplementedView next_destination = Destination(NotYetImplementedView) if next_destination.skip_current_view: # Remove the current View from history; it's forwarding us straight # to the next View so it should be as if this View never happened. current_view = self.back_stack.pop() - print(f"Skipping current view: {current_view}") + logger.info(f"Skipping current view: {current_view}") # Hang on to this reference... clear_history = next_destination.clear_history @@ -261,24 +355,76 @@ class Controller(Singleton): # Do not push a "new" destination if it is the same as the current one on # the top of the stack. if len(self.back_stack) == 0 or self.back_stack[-1] != next_destination: - print(f"Appending next destination: {next_destination}") + logger.info(f"Appending next destination: {next_destination}") self.back_stack.append(next_destination) else: - print(f"NOT appending {next_destination}") - - print("-" * 30) + logger.info(f"NOT appending {next_destination}") finally: - if self.screensaver.is_running: + from seedsigner.gui.renderer import Renderer + if self.is_screensaver_running: self.screensaver.stop() + + if self.toast_notification_thread and self.toast_notification_thread.is_alive(): + self.toast_notification_thread.stop() # Clear the screen when exiting - print("Clearing screen, exiting") + logger.info("Clearing screen, exiting") Renderer.get_instance().display_blank_screen() + @property + def is_screensaver_running(self): + return self.screensaver is not None and self.screensaver.is_running + + def start_screensaver(self): + # If a toast is running, tell it to give up the Renderer.lock; it will then + # block until the screensaver is done, at which point the toast can re-acquire + # the Renderer.lock and resume where it left off. + if self.toast_notification_thread and self.toast_notification_thread.is_alive(): + logger.info(f"Controller: settings toggle_render_lock for {self.toast_notification_thread.__class__.__name__}") + self.toast_notification_thread.toggle_renderer_lock() + + logger.info("Controller: Starting screensaver") + if not self.screensaver: + # Do a lazy/late import and instantiation to reduce Controller initial startup time + from seedsigner.views.screensaver import ScreensaverScreen + from seedsigner.hardware.buttons import HardwareButtons + self.screensaver = ScreensaverScreen(HardwareButtons.get_instance()) + + # Start the screensaver, but it will block until it can acquire the Renderer.lock. self.screensaver.start() + logger.info("Controller: Screensaver started") + + + def reset_screensaver_timeout(self): + """ + Reset the screensaver's timeout starting point to right now (i.e. make it think + that zero time has elapsed since the last user interaction). + """ + from seedsigner.hardware.buttons import HardwareButtons + HardwareButtons.get_instance().update_last_input_time() + + + def activate_toast(self, toast_manager_thread: BaseToastOverlayManagerThread): + """ + Ensures that the Controller has explicit control over which processes get to + claim the Renderer.lock and which need to (potentially) release it. + """ + if self.is_screensaver_running: + # New toast notifications break out of the Screensaver + logger.info("Controller: stopping screensaver") + self.screensaver.stop() + + if self.toast_notification_thread and self.toast_notification_thread.is_alive(): + # Can only run one toast at a time + logger.info(f"Controller: stopping {self.toast_notification_thread.__class__.__name__}") + self.toast_notification_thread.stop() + + self.toast_notification_thread = toast_manager_thread + logger.info(f"Controller: starting {self.toast_notification_thread.__class__.__name__}") + self.toast_notification_thread.start() def handle_exception(self, e) -> Destination: @@ -291,6 +437,7 @@ class Controller(Singleton): * python file, line num, method name * Exception message """ + from seedsigner.views.view import UnhandledExceptionView logger.exception(e) # The final exception output line is: @@ -298,7 +445,12 @@ class Controller(Singleton): # So we extract the Exception type and trim off any "foo.bar." namespacing: last_line = traceback.format_exc().splitlines()[-1] exception_type = last_line.split(":")[0].split(".")[-1] - exception_msg = last_line.split(":")[1] + + # Extract the error message, if there is one + if ":" in last_line: + exception_msg = last_line.split(":")[1] + else: + exception_msg = "" # Scan for the last debugging line that includes a line number reference line_info = None diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 9e6ec211..9193e451 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -1,15 +1,20 @@ +import logging import math import os import pathlib +import re +from time import time from dataclasses import dataclass from decimal import Decimal from PIL import Image, ImageDraw, ImageFont, ImageFilter from typing import List, Tuple -from seedsigner.models import Singleton from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants +from seedsigner.models.singleton import Singleton + +logger = logging.getLogger(__name__) # TODO: Remove all pixel hard coding @@ -20,18 +25,22 @@ class GUIConstants: BACKGROUND_COLOR = "black" WARNING_COLOR = "#FFD60A" - DIRE_WARNING_COLOR = "red" - SUCCESS_COLOR = "#00dd00" - BITCOIN_ORANGE = "#ff9416" - ACCENT_COLOR = "orange" - TESTNET_COLOR = "#00f100" - REGTEST_COLOR = "#00caf1" + DIRE_WARNING_COLOR = "#FF453A" + SUCCESS_COLOR = "#30D158" + INFO_COLOR = "#0084FF" + ACCENT_COLOR = "#FF9F0A" + BITCOIN_ORANGE = "#FF9416" + TESTNET_COLOR = "#00F100" + REGTEST_COLOR = "#00CAF1" + GREEN_INDICATOR_COLOR = "#00FF00" + INACTIVE_COLOR = "#414141" ICON_FONT_NAME__FONT_AWESOME = "Font_Awesome_6_Free-Solid-900" - ICON_FONT_NAME__SEEDSIGNER = "seedsigner-glyphs" + ICON_FONT_NAME__SEEDSIGNER = "seedsigner-icons" ICON_FONT_SIZE = 22 ICON_INLINE_FONT_SIZE = 24 - ICON_LARGE_BUTTON_SIZE = 36 + ICON_LARGE_BUTTON_SIZE = 48 + ICON_TOAST_FONT_SIZE = 30 ICON_PRIMARY_SCREEN_SIZE = 50 TOP_NAV_TITLE_FONT_NAME = "OpenSans-SemiBold" @@ -43,21 +52,23 @@ class GUIConstants: BODY_FONT_SIZE = 17 BODY_FONT_MAX_SIZE = TOP_NAV_TITLE_FONT_SIZE BODY_FONT_MIN_SIZE = 15 - BODY_FONT_COLOR = "#f8f8f8" + BODY_FONT_COLOR = "#FCFCFC" BODY_LINE_SPACING = COMPONENT_PADDING FIXED_WIDTH_FONT_NAME = "Inconsolata-Regular" FIXED_WIDTH_EMPHASIS_FONT_NAME = "Inconsolata-SemiBold" LABEL_FONT_SIZE = BODY_FONT_MIN_SIZE - LABEL_FONT_COLOR = "#777" + LABEL_FONT_COLOR = "#777777" BUTTON_FONT_NAME = "OpenSans-SemiBold" BUTTON_FONT_SIZE = 18 - BUTTON_FONT_COLOR = "#e8e8e8" - BUTTON_BACKGROUND_COLOR = "#2c2c2c" + BUTTON_FONT_COLOR = "#FCFCFC" + BUTTON_BACKGROUND_COLOR = "#2C2C2C" BUTTON_HEIGHT = 32 - BUTTON_SELECTED_FONT_COLOR = "black" + BUTTON_SELECTED_FONT_COLOR = BACKGROUND_COLOR + + NOTIFICATION_COLOR = "#00F100" @@ -67,11 +78,8 @@ class FontAwesomeIconConstants: ANGLE_RIGHT = "\uf105" ANGLE_UP = "\uf106" CAMERA = "\uf030" - CARET_DOWN = "\uf0d7" - CARET_LEFT = "\uf0d9" - CARET_RIGHT = "\uf0da" - CARET_UP = "\uf0d8" - SOLID_CIRCLE_CHECK = "\uf058" + CHEVRON_UP = "\uf077" + CHEVRON_DOWN = "\uf078" CIRCLE = "\uf111" CIRCLE_CHEVRON_RIGHT = "\uf138" DICE = "\uf522" @@ -81,46 +89,64 @@ class FontAwesomeIconConstants: DICE_FOUR = "\uf524" DICE_FIVE = "\uf523" DICE_SIX = "\uf526" - GEAR = "\uf013" - KEY = "\uf084" KEYBOARD = "\uf11c" LOCK = "\uf023" MAP = "\uf279" PAPER_PLANE = "\uf1d8" PEN = "\uf304" - PLUS = "+" - POWER_OFF = "\uf011" - ROTATE_RIGHT = "\uf2f9" - SCREWDRIVER_WRENCH = "\uf7d9" - SQUARE = "\uf0c8" SQUARE_CARET_DOWN = "\uf150" SQUARE_CARET_LEFT = "\uf191" SQUARE_CARET_RIGHT = "\uf152" SQUARE_CARET_UP = "\uf151" - SQUARE_CHECK = "\uf14a" - TRIANGLE_EXCLAMATION = "\uf071" UNLOCK = "\uf09c" - QRCODE = "\uf029" X = "\u0058" +class SeedSignerIconConstants: + # Menu icons + SCAN = "\ue900" + SEEDS = "\ue901" + SETTINGS = "\ue902" + TOOLS = "\ue903" -class SeedSignerCustomIconConstants: - LARGE_CHEVRON_LEFT = "\ue900" - SMALL_CHEVRON_RIGHT = "\ue901" - PAGE_UP = "\ue903" - PAGE_DOWN = "\ue902" - PLUS = "\ue904" - CIRCLE_CHECK = "\ue907" - CIRCLE_EXCLAMATION = "\ue908" - CIRCLE_X = "\ue909" - FINGERPRINT = "\ue90a" - PATH = "\ue90b" - BITCOIN_LOGO_STRAIGHT = "\ue90c" - BITCOIN_LOGO_TILTED = "\ue90d" + # Utility icons + BACK = "\ue904" + CHECK = "\ue905" + CHECKBOX = "\ue906" + CHECKBOX_SELECTED = "\ue907" + CHEVRON_DOWN = "\ue908" + CHEVRON_LEFT = "\ue909" + CHEVRON_RIGHT = "\ue90a" + CHEVRON_UP = "\ue90b" + CLOSE = "\ue90c" + PAGE_DOWN = "\ue90d" + PAGE_UP = "\ue90e" + PLUS = "\ue90f" + POWER = "\ue910" + RESTART = "\ue911" - MIN_VALUE = LARGE_CHEVRON_LEFT - MAX_VALUE = BITCOIN_LOGO_TILTED + # Messaging icons + ERROR = "\ue912" + SUCCESS = "\ue913" + WARNING = "\ue914" + + # Informational icons + ADDRESS = "\ue915" + CHANGE = "\ue916" + DERIVATION = "\ue917" + FEE = "\ue918" + FINGERPRINT = "\ue919" + PASSPHRASE = "\ue91a" + + # Misc icons + BITCOIN = "\ue91b" + BITCOIN_ALT = "\ue91c" + BRIGHTNESS = "\ue91d" + MICROSD = "\ue91e" + QRCODE = "\ue91f" + + MIN_VALUE = SCAN + MAX_VALUE = QRCODE @@ -194,7 +220,7 @@ class Fonts(Singleton): cls.fonts[font_name][size] = ImageFont.truetype(os.path.join(cls.font_path, f"{font_name}.{file_extension}"), size) except OSError as e: if "cannot open resource" in str(e): - raise Exception(f"Font {font_name}.ttf not found: {repr(e)}") + raise Exception(f"Font {font_name}.{file_extension} not found: {repr(e)}") else: raise e @@ -256,7 +282,7 @@ class TextArea(BaseComponent): height: int = None # None = special case: autosize to min height screen_x: int = 0 screen_y: int = 0 - min_text_x: int = None + min_text_x: int = 0 # Text can not start at x any less than this background_color: str = GUIConstants.BACKGROUND_COLOR font_name: str = GUIConstants.BODY_FONT_NAME font_size: int = GUIConstants.BODY_FONT_SIZE @@ -266,6 +292,7 @@ class TextArea(BaseComponent): supersampling_factor: int = 1 auto_line_break: bool = True allow_text_overflow: bool = False + height_ignores_below_baseline: bool = False # If True, characters that render below the baseline (e.g. "pqgy") will not affect the final height calculation def __post_init__(self): @@ -273,147 +300,133 @@ class TextArea(BaseComponent): if not self.width: self.width = self.canvas_width + + if self.screen_x + self.width > self.canvas_width: + self.width = self.canvas_width - self.screen_x - if self.font_size < 18 and (not self.supersampling_factor or self.supersampling_factor == 1): - self.supersampling_factor = 2 - - self.font = Fonts.get_font(self.font_name, int(self.supersampling_factor * self.font_size)) - self.supersampled_width = self.supersampling_factor * self.width - if self.height is None: - self.supersampled_height = None - else: - self.supersampled_height = self.supersampling_factor * self.height self.line_spacing = GUIConstants.BODY_LINE_SPACING # We have to figure out if and where to make line breaks in the text so that it # fits in its bounding rect (plus accounting for edge padding) using its given # font. - # Measure from left baseline ("ls") - # TODO: getbbox() seems to ignore "\n" so isn't properly factored into height - # calcs and yields incorrect full_text_width. For now must specify self.height to - # render properly. Centering will be wrong. - (left, top, full_text_width, bottom) = self.font.getbbox(self.text, anchor="ls") - self.text_font_height = -1 * top - self.bbox_height = self.text_font_height + bottom + # Do initial calcs without worrying about supersampling. + self.text_lines = reflow_text_for_width( + text=self.text, + width=self.width - 2*self.edge_padding, + font_name=self.font_name, + font_size=self.font_size, + allow_text_overflow=self.allow_text_overflow, + ) - # Stores each line of text and its rendering starting x-coord - self.text_lines = [] - self.text_width = 0 - def _add_text_line(text, width): - if self.is_text_centered: - text_x = int((self.supersampled_width - width) / 2) - else: - text_x = self.supersampling_factor * self.edge_padding - if self.min_text_x is not None and text_x < self.min_text_x: - text_x = self.min_text_x - self.text_lines.append({"text": text, "text_x": text_x}) + # Calculate the actual font height from the "baseline" anchor ("_s") + font = Fonts.get_font(self.font_name, self.font_size) - if width > self.text_width: - self.text_width = width + # Note: from the baseline anchor, `top` is a negative number while `bottom` + # conveys the height of the pixels that rendered below the baseline, if any + # (e.g. "py" in "python"). + (left, top, right, bottom) = font.getbbox(self.text, anchor="ls") + self.text_height_above_baseline = -1 * top + self.text_height_below_baseline = bottom - if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): - # The whole text fits on one line - _add_text_line(self.text, full_text_width) + # Initialize the text rendering relative to the baseline + self.text_y = self.text_height_above_baseline - if self.height is None: - self.text_y = self.text_font_height - self.supersampled_height = self.bbox_height - self.height = int(self.bbox_height / self.supersampling_factor) - else: - # Vertical starting point calc is easy in this case - self.text_y = self.text_font_height + int((self.supersampled_height - self.text_font_height)/2) - - self.text_width = full_text_width + # Other components, like IconTextLine will need to know how wide the actual + # rendered text will be, separate from the TextArea's defined overall `width`. + self.text_width = max(line["text_width"] for line in self.text_lines) + + # Calculate the actual height + if len(self.text_lines) == 1: + total_text_height = self.text_height_above_baseline + if not self.height_ignores_below_baseline: + total_text_height += self.text_height_below_baseline + else: + # Multiply for the number of lines plus the spacer + total_text_height = self.text_height_above_baseline * len(self.text_lines) + self.line_spacing * (len(self.text_lines) - 1) + + if not self.height_ignores_below_baseline and re.findall(f"[gjpqy]", self.text_lines[-1]["text"]): + # Last line has at least one char that dips below baseline + total_text_height += self.text_height_below_baseline + + if self.height is None: + # Autoscale height to text lines + self.height = total_text_height else: - # Have to calc how to break text into multiple lines - def _binary_len_search(min_index, max_index): - # Try the middle of the range - index = math.ceil((max_index + min_index) / 2) - if index == 0: - # Handle edge case where there's only one word in the last line - index = 1 - - tw, th = self.font.getsize(" ".join(words[0:index])) - - if tw > self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): - # Candidate line is still too long. Restrict search range down. - if min_index + 1 == index: - if index == 1: - # It's just one long, unbreakable word. There's no good - # solution here. Just accept it as is and let it render off - # the edges. - return (index, tw) - else: - # There's still room to back down the min_index in the next - # round. - index -= 1 - return _binary_len_search(min_index=min_index, max_index=index) - elif index == max_index: - # We have converged - return (index, tw) + if total_text_height > self.height: + if not self.allow_text_overflow: + raise TextDoesNotFitException(f"Text cannot fit in target rect with this font/size\n\ttotal_text_height: {total_text_height} | self.height: {self.height}") else: - # Candidate line is possibly shorter than necessary. - return _binary_len_search(min_index=index, max_index=max_index) - - if len(self.text.split()) == 1 and not self.allow_text_overflow: - # No whitespace chars to split on! - raise TextDoesNotFitException("Text cannot fit in target rect with this font/size") - - for line in self.text.split("\n"): - words = line.split() - if not words: - # It's a blank line - _add_text_line("", 0) - else: - while words: - (index, tw) = _binary_len_search(0, len(words)) - _add_text_line(" ".join(words[0:index]), tw) - words = words[index:] - - # TODO: Don't render blank lines as full height - total_text_height = self.bbox_height * len(self.text_lines) + self.line_spacing * (len(self.text_lines) - 1) - if self.height is None: - # Autoscale height to text lines - self.supersampled_height = total_text_height - self.height = int(self.supersampled_height / self.supersampling_factor) - self.text_y = self.text_font_height + # Just let it render off the edge, but preserve the top portion + pass else: - self.supersampled_height = self.height * self.supersampling_factor - if total_text_height > self.height * self.supersampling_factor + 2*GUIConstants.COMPONENT_PADDING * self.supersampling_factor: - if not self.allow_text_overflow: - raise TextDoesNotFitException("Text cannot fit in target rect with this font/size") - else: - # Just let it render off the edge, but preserve the top portion - self.text_y = self.text_font_height - - else: - # Vertically center the multiline text's starting point - self.text_y = self.text_font_height + int((self.supersampled_height - total_text_height)/2) - - # Make sure the width/height that get referenced outside this obj are - # specified and restored to their normal scaling factor. - self.width = int(self.text_width / self.supersampling_factor) - self.text_font_height = int(self.text_font_height / self.supersampling_factor) + # Vertically center the text's starting point + self.text_y += int(self.height - total_text_height)/2 def render(self): # Render to a temp img scaled up by self.supersampling_factor, then resize down # with bicubic resampling. - # TODO: Store resulting super-sampled image as a member var in __post_init__ and - # just re-paste it here. - img = Image.new("RGB", (self.supersampled_width, self.supersampled_height), self.background_color) + # Add a `resample_padding` above and below when supersampling to avoid edge + # effects (resized text that's right up against the top/bottom gets slightly + # dimmer at the edge otherwise). + if self.font_size < 20 and (not self.supersampling_factor or self.supersampling_factor == 1): + self.supersampling_factor = 2 + + actual_text_height = self.height + if self.height_ignores_below_baseline: + # Even though we're ignoring the pixels below the baseline for spacing + # purposes, we have to make sure we don't crop those pixels out during the + # supersampling operations here. + actual_text_height += self.text_height_below_baseline + + resample_padding = 10 if self.supersampling_factor > 1.0 else 0 + img = Image.new( + "RGBA", + ( + self.width * self.supersampling_factor, + (actual_text_height + 2*resample_padding) * self.supersampling_factor + ), + self.background_color + ) draw = ImageDraw.Draw(img) - cur_y = self.text_y + + cur_y = (self.text_y + resample_padding) * self.supersampling_factor + supersampled_font = Fonts.get_font(self.font_name, int(self.supersampling_factor * self.font_size)) + + if self.is_text_centered: + anchor = "ms" + else: + anchor = "ls" + + # Position where we'll render each line of text + text_x = self.edge_padding for line in self.text_lines: - draw.text((line["text_x"], cur_y), line["text"], fill=self.font_color, font=self.font, anchor="ls") - cur_y += self.bbox_height + self.line_spacing + if self.is_text_centered: + # We'll render with a centered anchor so we just need the midpoint + text_x = int(self.width/2) + if text_x - int(line["text_width"]/2) < self.min_text_x: + # The left edge of the centered text will protrude too far; nudge it right + text_x = self.min_text_x + int(line["text_width"]/2) - resized = img.resize((int(self.supersampled_width / self.supersampling_factor), self.height), Image.LANCZOS) - resized = resized.filter(ImageFilter.SHARPEN) - self.canvas.paste(resized, (self.screen_x, self.screen_y)) + draw.text((text_x * self.supersampling_factor, cur_y), line["text"], fill=self.font_color, font=supersampled_font, anchor=anchor) + + # Debugging: show the exact vertical extents of each line of text + # draw.line((0, cur_y - self.text_height_above_baseline * self.supersampling_factor, self.width * self.supersampling_factor, cur_y - self.text_height_above_baseline * self.supersampling_factor), fill="red", width=int(self.supersampling_factor)) + # draw.line((0, cur_y, self.width * self.supersampling_factor, cur_y), fill="red", width=int(self.supersampling_factor)) + + cur_y += (self.text_height_above_baseline + self.line_spacing) * self.supersampling_factor + + # Crop off the top_padding and resize the result down to onscreen size + if self.supersampling_factor > 1.0: + resized = img.resize((self.width, actual_text_height + 2*resample_padding), Image.LANCZOS) + sharpened = resized.filter(ImageFilter.SHARPEN) + + # Crop args are actually (left, top, WIDTH, HEIGHT) + img = sharpened.crop((0, resample_padding, self.width, actual_text_height + resample_padding)) + self.canvas.paste(img, (self.screen_x, self.screen_y)) @@ -421,14 +434,14 @@ class TextArea(BaseComponent): class Icon(BaseComponent): screen_x: int = 0 screen_y: int = 0 - icon_name: str = SeedSignerCustomIconConstants.BITCOIN_LOGO_TILTED + icon_name: str = SeedSignerIconConstants.BITCOIN_ALT icon_size: int = GUIConstants.ICON_FONT_SIZE icon_color: str = GUIConstants.BODY_FONT_COLOR def __post_init__(self): super().__post_init__() - if SeedSignerCustomIconConstants.MIN_VALUE <= self.icon_name and self.icon_name <= SeedSignerCustomIconConstants.MAX_VALUE: + if SeedSignerIconConstants.MIN_VALUE <= self.icon_name and self.icon_name <= SeedSignerIconConstants.MAX_VALUE: self.icon_font = Fonts.get_font(GUIConstants.ICON_FONT_NAME__SEEDSIGNER, self.icon_size, file_extension="otf") else: self.icon_font = Fonts.get_font(GUIConstants.ICON_FONT_NAME__FONT_AWESOME, self.icon_size, file_extension="otf") @@ -497,21 +510,21 @@ class IconTextLine(BaseComponent): canvas=self.canvas, text=self.label_text, font_size=GUIConstants.BODY_FONT_SIZE - 2, - font_color="#666", + font_color=GUIConstants.LABEL_FONT_COLOR, edge_padding=0, is_text_centered=self.is_text_centered if not self.icon_name else False, auto_line_break=False, screen_x=text_screen_x, screen_y=self.screen_y, - allow_text_overflow=False + allow_text_overflow=False, ) else: self.label_textarea = None value_textarea_screen_y = self.screen_y if self.label_text: - label_padding_y = GUIConstants.COMPONENT_PADDING - value_textarea_screen_y += self.label_textarea.text_font_height + label_padding_y + label_padding_y = int(GUIConstants.COMPONENT_PADDING / 2) + value_textarea_screen_y += self.label_textarea.height + label_padding_y self.value_textarea = TextArea( image_draw=self.image_draw, @@ -531,11 +544,11 @@ class IconTextLine(BaseComponent): if self.label_text: if not self.height: self.height = self.label_textarea.height + label_padding_y + self.value_textarea.height - max_textarea_width = max(self.label_textarea.width, self.value_textarea.width) + max_textarea_width = max(self.label_textarea.text_width, self.value_textarea.text_width) else: if not self.height: self.height = self.value_textarea.height - max_textarea_width = self.value_textarea.width + max_textarea_width = self.value_textarea.text_width # Now we can update the icon's y position if self.icon_name: @@ -544,17 +557,12 @@ class IconTextLine(BaseComponent): self.height = max(self.icon.height, self.height) - if self.is_text_centered: - if self.icon_name: - total_width = max_textarea_width + self.icon.width + self.icon_horizontal_spacer - self.icon.screen_x = self.screen_x + int((self.canvas_width - self.screen_x - total_width) / 2) - if self.label_text: - self.label_textarea.screen_x = self.icon.screen_x + self.icon.width + self.icon_horizontal_spacer - self.value_textarea.screen_x = self.icon.screen_x + self.icon.width + self.icon_horizontal_spacer - # else: - # if self.label_text: - # self.label_textarea.screen_x = self.screen_x + int((self.canvas_width - self.screen_x - max_textarea_width + (max_textarea_width - self.label_textarea.width))/2) - # self.value_textarea.screen_x = self.screen_x + int((self.canvas_width - self.screen_x - max_textarea_width + (max_textarea_width - self.value_textarea.width))/2) + if self.is_text_centered and self.icon_name: + total_width = max_textarea_width + self.icon.width + self.icon_horizontal_spacer + self.icon.screen_x = self.screen_x + int((self.canvas_width - self.screen_x - total_width) / 2) + if self.label_text: + self.label_textarea.screen_x = self.icon.screen_x + self.icon.width + self.icon_horizontal_spacer + self.value_textarea.screen_x = self.icon.screen_x + self.icon.width + self.icon_horizontal_spacer self.width = self.canvas_width @@ -607,7 +615,8 @@ class FormattedAddress(BaseComponent): self.accent_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, self.font_size) # Fixed width font means we only have to measure one max-height character - char_width, char_height = self.font.getsize("Q") + left, top, right, bottom = self.font.getbbox("Q") + char_width, char_height = right - left, bottom - top n = 7 display_str = f"{self.address[:n]} {self.address[n:-1*n]} {self.address[-1*n:]}" @@ -730,7 +739,7 @@ class FormattedAddress(BaseComponent): )) remaining_display_str = remaining_display_str[max_chars_per_line:] - cur_y += char_height + cur_y += char_height + GUIConstants.BODY_LINE_SPACING self.height = cur_y @@ -790,7 +799,7 @@ class BtcAmount(BaseComponent): btc_icon = Icon( image_draw=draw, canvas=self.paste_image, - icon_name=SeedSignerCustomIconConstants.BITCOIN_LOGO_TILTED, + icon_name=SeedSignerIconConstants.BITCOIN_ALT, icon_color=btc_color, icon_size=self.icon_size, screen_x=0, @@ -824,7 +833,7 @@ class BtcAmount(BaseComponent): # font = smaller_digit_font (left, top, text_width, bottom) = font.getbbox(btc_text, anchor="ls") - text_height = -1 * top + text_height = -1 * top + bottom text_y = self.paste_image.height - int((self.paste_image.height - text_height)/2) draw.text( @@ -851,7 +860,7 @@ class BtcAmount(BaseComponent): if self.total_sats > 1e9: font = smaller_digit_font (left, top, text_width, bottom) = font.getbbox(sats_text, anchor="ls") - text_height = -1 * top + text_height = -1 * top + bottom text_y = self.paste_image.height - int((self.paste_image.height - text_height)/2) draw.text( xy=( @@ -878,7 +887,7 @@ class BtcAmount(BaseComponent): btc_icon = Icon( image_draw=draw, canvas=self.paste_image, - icon_name=SeedSignerCustomIconConstants.BITCOIN_LOGO_TILTED, + icon_name=SeedSignerIconConstants.BITCOIN_ALT, icon_color=btc_color, icon_size=self.icon_size, screen_x=0, @@ -888,7 +897,7 @@ class BtcAmount(BaseComponent): cur_x = btc_icon.width + int(GUIConstants.COMPONENT_PADDING/4) (left, top, text_width, bottom) = smaller_digit_font.getbbox(btc_text, anchor="ls") - text_height = -1 * top + text_height = -1 * top + bottom text_y = self.paste_image.height - int((self.paste_image.height - text_height)/2) draw.text( @@ -954,7 +963,7 @@ class BtcAmount(BaseComponent): ) unit_textarea.render() - final_x = cur_x + GUIConstants.COMPONENT_PADDING + unit_textarea.width + final_x = cur_x + GUIConstants.COMPONENT_PADDING + unit_text_width self.paste_image = self.paste_image.crop((0, 0, final_x, self.paste_image.height)) self.paste_coords = ( @@ -1032,7 +1041,9 @@ class Button(BaseComponent): (left, top, self.text_width, bottom) = self.font.getbbox(self.text, anchor="ls") # print(f"left: {left} | top: {top} | right: {self.text_width} | bottom: {bottom}") - # Note: "top" is negative when measured from a "baseline" anchor + # Note: "top" is negative when measured from a "baseline" anchor. Intentionally + # ignore any chars below the baseline for consistent vertical positioning + # regardless of the Button text. self.text_height = -1 * top # TODO: Only apply screen_y at render @@ -1134,8 +1145,8 @@ class CheckedSelectionButton(Button): def __post_init__(self): self.is_text_centered = False - self.icon_name = FontAwesomeIconConstants.SOLID_CIRCLE_CHECK - self.icon_color = "#00dd00" + self.icon_name = SeedSignerIconConstants.CHECK + self.icon_color = GUIConstants.SUCCESS_COLOR super().__post_init__() if not self.is_checked: @@ -1153,10 +1164,10 @@ class CheckboxButton(Button): def __post_init__(self): self.is_text_centered = False if self.is_checked: - self.icon_name = FontAwesomeIconConstants.SQUARE_CHECK - self.icon_color = "#00dd00" + self.icon_name = SeedSignerIconConstants.CHECKBOX_SELECTED + self.icon_color = GUIConstants.SUCCESS_COLOR else: - self.icon_name = FontAwesomeIconConstants.SQUARE + self.icon_name = SeedSignerIconConstants.CHECKBOX self.icon_color = GUIConstants.BODY_FONT_COLOR super().__post_init__() @@ -1195,7 +1206,7 @@ class TopNav(BaseComponent): icon_color: str = GUIConstants.BODY_FONT_COLOR font_name: str = GUIConstants.TOP_NAV_TITLE_FONT_NAME font_size: int = GUIConstants.TOP_NAV_TITLE_FONT_SIZE - font_color: str = "#fcfcfc" + font_color: str = GUIConstants.BODY_FONT_COLOR show_back_button: bool = True show_power_button: bool = False is_selected: bool = False @@ -1210,17 +1221,17 @@ class TopNav(BaseComponent): if self.show_back_button: self.left_button = IconButton( - icon_name=SeedSignerCustomIconConstants.LARGE_CHEVRON_LEFT, + icon_name=SeedSignerIconConstants.BACK, icon_size=GUIConstants.ICON_INLINE_FONT_SIZE, screen_x=GUIConstants.EDGE_PADDING, - screen_y=GUIConstants.EDGE_PADDING, + screen_y=GUIConstants.EDGE_PADDING - 1, # Text can't perfectly vertically center relative to the button; shifting it down 1px looks better. width=GUIConstants.TOP_NAV_BUTTON_SIZE, height=GUIConstants.TOP_NAV_BUTTON_SIZE, ) if self.show_power_button: self.right_button = IconButton( - icon_name=FontAwesomeIconConstants.POWER_OFF, + icon_name=SeedSignerIconConstants.POWER, icon_size=GUIConstants.ICON_INLINE_FONT_SIZE, screen_x=self.width - GUIConstants.TOP_NAV_BUTTON_SIZE - GUIConstants.EDGE_PADDING, screen_y=GUIConstants.EDGE_PADDING, @@ -1228,10 +1239,10 @@ class TopNav(BaseComponent): height=GUIConstants.TOP_NAV_BUTTON_SIZE, ) - min_x = 0 + min_text_x = 0 if self.show_back_button: # Don't let the title intrude on the BACK button - min_x = self.left_button.screen_x + self.left_button.width + GUIConstants.COMPONENT_PADDING + min_text_x = self.left_button.screen_x + self.left_button.width + GUIConstants.COMPONENT_PADDING if self.icon_name: self.title = IconTextLine( @@ -1250,12 +1261,14 @@ class TopNav(BaseComponent): self.title = TextArea( screen_x=0, screen_y=0, - min_text_x=min_x, + min_text_x=min_text_x, + width=self.width, height=self.height, text=self.text, is_text_centered=True, font_name=self.font_name, font_size=self.font_size, + height_ignores_below_baseline=True, # Consistently vertically center text, ignoring chars that render below baseline (e.g. "pqyj") ) @@ -1283,15 +1296,6 @@ class TopNav(BaseComponent): self.right_button.is_selected = self.is_selected self.right_button.render() - # self.image_draw.text( - # (self.text_x, self.text_y), - # self.text, - # font=self.font, - # fill=self.font_color, - # stroke_width=1, - # stroke_fill=GUIConstants.BACKGROUND_COLOR, - # ) - def linear_interp(a, b, t): @@ -1330,3 +1334,133 @@ def calc_bezier_curve(p1: Tuple[int,int], p2: Tuple[int,int], p3: Tuple[int,int] return points + + +def reflow_text_for_width(text: str, + width: int, + font_name=GUIConstants.BODY_FONT_NAME, + font_size=GUIConstants.BODY_FONT_SIZE, + allow_text_overflow: bool=False) -> list[dict]: + """ + Reflows text to fit within `width` by breaking long lines up. + + Returns a List with each reflowed line of text as its own entry. + + Note: It is up to the calling code to handle any height considerations for the + resulting lines of text. + """ + # We have to figure out if and where to make line breaks in the text so that it + # fits in its bounding rect (plus accounting for edge padding) using its given + # font. + start = time() + font = Fonts.get_font(font_name=font_name, size=font_size) + # Measure from left baseline ("ls") + (left, top, full_text_width, bottom) = font.getbbox(text, anchor="ls") + + # Stores each line of text and its rendering starting x-coord + text_lines = [] + def _add_text_line(text, text_width): + text_lines.append({"text": text, "text_width": text_width}) + + if "\n" not in text and full_text_width < width: + # The whole text fits on one line + _add_text_line(text, full_text_width) + + else: + # Have to calc how to break text into multiple lines + def _binary_len_search(min_index, max_index): + # Try the middle of the range + index = math.ceil((max_index + min_index) / 2) + if index == 0: + # Handle edge case where there's only one word in the last line + index = 1 + + # Measure rendered width from "left" anchor (anchor="l_") + (left, top, right, bottom) = font.getbbox(" ".join(words[0:index]), anchor="ls") + line_width = right - left + + if line_width >= width: + # Candidate line is still too long. Restrict search range down. + if min_index + 1 == index: + if index == 1: + # It's just one long, unbreakable word. There's no good + # solution here. Just accept it as is and let it render off + # the edges. + return (index, line_width) + else: + # There's still room to back down the min_index in the next + # round. + index -= 1 + return _binary_len_search(min_index=min_index, max_index=index) + elif index == max_index: + # We have converged + return (index, line_width) + else: + # Candidate line is possibly shorter than necessary. + return _binary_len_search(min_index=index, max_index=max_index) + + if len(text.split()) == 1 and not allow_text_overflow: + # No whitespace chars to split on! + raise TextDoesNotFitException("Text cannot fit in target rect with this font+size") + + # Now we're ready to go line-by-line into our line break binary search! + for line in text.split("\n"): + words = line.split() + if not words: + # It's a blank line + _add_text_line("", 0) + else: + while words: + (index, tw) = _binary_len_search(0, len(words)) + _add_text_line(" ".join(words[0:index]), tw) + words = words[index:] + + return text_lines + + + +def reflow_text_into_pages(text: str, + width: int, + height: int, + font_name=GUIConstants.BODY_FONT_NAME, + font_size=GUIConstants.BODY_FONT_SIZE, + line_spacer: int = GUIConstants.BODY_LINE_SPACING, + allow_text_overflow: bool=False) -> list[str]: + """ + Invokes `reflow_text_for_width` above to convert long text into width-limited + individual text lines and then calculates how many lines will fit on a "page" and + groups the output accordingly. + + Returns a list of strings where each string is a page's worth of line-breaked text. + """ + reflowed_lines_dicts = reflow_text_for_width(text=text, + width=width, + font_name=font_name, + font_size=font_size, + allow_text_overflow=allow_text_overflow) + + lines = [] + for line_dict in reflowed_lines_dicts: + lines.append(line_dict["text"]) + logging.info(f"""{line_dict["text_width"]:3}: {line_dict["text"]}""") + + font = Fonts.get_font(font_name=font_name, size=font_size) + # Measure the font's height above baseline and how for below it certain characters + # (e.g. lowercase "g") can render. + (left, top, right, bottom) = font.getbbox("Agjpqy", anchor="ls") + font_height_above_baseline = -1 * top + font_height_below_baseline = bottom + + # I'm sure there's a smarter way to do this... + lines_per_page = 0 + for i in range(1, height): + if height > font_height_above_baseline * i + line_spacer * (i-1) + font_height_below_baseline: + lines_per_page = i + else: + break + + pages = [] + for i in range(0, len(lines), lines_per_page): + pages.append("\n".join(lines[i:i+lines_per_page])) + + return pages diff --git a/src/seedsigner/gui/keyboard.py b/src/seedsigner/gui/keyboard.py index 09169903..96f83441 100644 --- a/src/seedsigner/gui/keyboard.py +++ b/src/seedsigner/gui/keyboard.py @@ -39,6 +39,12 @@ class Keyboard: "font": COMPACT_KEY_FONT, "size": 1, } + KEY_SPACE_2 = { + "code": "SPACE", + "letter": "space", + "font": COMPACT_KEY_FONT, + "size": 2, + } KEY_SPACE_3 = { "code": "SPACE", "letter": "space", @@ -117,6 +123,7 @@ class Keyboard: rect_color = self.keyboard.deactivated_background_color font_color = "#333" # Show the letter but render as gray outline_color = self.keyboard.deactivated_background_color + if self.is_selected: # Inactive, selected just gets highlighted outline outline_color = self.keyboard.highlight_color @@ -125,11 +132,11 @@ class Keyboard: font_color = "black" else: if self.is_additional_key: - # rect_color = "#111" - rect_color = self.keyboard.background_color + rect_color = "#000" + font_color = "#999" else: rect_color = self.keyboard.background_color - font_color = "#e8e8e8" + font_color = "#e8e8e8" self.keyboard.draw.rounded_rectangle( ( @@ -186,6 +193,7 @@ class Keyboard: self.auto_wrap = auto_wrap self.background_color = GUIConstants.BUTTON_BACKGROUND_COLOR self.deactivated_background_color = GUIConstants.BACKGROUND_COLOR + self.additional_key_deactivated_background_color = GUIConstants.BACKGROUND_COLOR self.highlight_color = highlight_color # Does the specified layout work? @@ -583,7 +591,8 @@ class TextEntryDisplay(TextEntryDisplayConstants): if end_pos_x < self.width: # The entire cur_text plus the cursor bar fits self.text_offset = 3 + cursor_bar_serif_half_width - tw_left, th = self.font.getsize(self.cur_text[:cursor_position]) + left, top, right, bottom = self.font.getbbox(self.cur_text[:cursor_position]) + tw_left, th = right - left, bottom - top cursor_bar_x = self.text_offset + tw_left else: @@ -591,7 +600,8 @@ class TextEntryDisplay(TextEntryDisplayConstants): cursor_position = len(self.cur_text) # Is the cursor at either extreme? - tw_left, th = self.font.getsize(self.cur_text[:cursor_position]) + left, top, right, bottom = self.font.getbbox(self.cur_text[:cursor_position]) + tw_left, th = right - left, bottom - top if self.text_offset + tw_left + cursor_bar_serif_half_width + 3 >= self.width: # Cursor is at the extreme right; have to push the full tw_right off diff --git a/src/seedsigner/gui/renderer.py b/src/seedsigner/gui/renderer.py index 1191eeec..9534a774 100644 --- a/src/seedsigner/gui/renderer.py +++ b/src/seedsigner/gui/renderer.py @@ -1,9 +1,9 @@ -from PIL import Image, ImageDraw, ImageFont +from PIL import Image, ImageDraw from threading import Lock from seedsigner.gui.components import Fonts, GUIConstants from seedsigner.hardware.ST7789 import ST7789 -from seedsigner.models import ConfigurableSingleton +from seedsigner.models.singleton import ConfigurableSingleton @@ -19,8 +19,6 @@ class Renderer(ConfigurableSingleton): @classmethod def configure_instance(cls): - from seedsigner.models.settings import Settings - # Instantiate the one and only Renderer instance renderer = cls.__new__(cls) cls._instance = renderer @@ -34,7 +32,12 @@ class Renderer(ConfigurableSingleton): renderer.draw = ImageDraw.Draw(renderer.canvas) - def show_image(self, image=None, alpha_overlay=None): + def show_image(self, image=None, alpha_overlay=None, show_direct=False): + if show_direct: + # Use the incoming image as the canvas and immediately render + self.disp.ShowImage(image, 0, 0) + return + if alpha_overlay: if image == None: image = self.canvas @@ -81,125 +84,7 @@ class Renderer(ConfigurableSingleton): self.disp.ShowImage(crop, 0, 0) - # TODO: Remove all references - def show_image_with_text(self, image, text, font=None, text_color="GREY", text_background=None): - image_copy = image.copy().convert("RGBA") - - text_overlay = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0)) - text_overlay_draw = ImageDraw.Draw(text_overlay) - if not font: - font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BODY_FONT_SIZE) - tw, th = text_overlay_draw.textsize(text, font=font) - if text_background: - text_overlay_draw.rectangle(((240 - tw) / 2 - 3, 240 - th, (240 - tw) / 2 + tw + 3, 240), fill=text_background) - text_overlay_draw.text(((240 - tw) / 2, 240 - th - 1), text, fill=text_color, font=font) - - self.show_image(image_copy, alpha_overlay=text_overlay) - - - # TODO: Should probably move this to screens.py - def draw_modal(self, lines = [], title = "", bottom = "") -> None: - self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0) - - if len(title) > 0: - tw, th = self.draw.textsize(title, font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 2), title, fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - - if len(bottom) > 0: - tw, th = self.draw.textsize(bottom, font=Fonts.get_font("Assistant-Medium", 18)) - self.draw.text(((240 - tw) / 2, 210), bottom, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18)) - - if len(lines) == 1: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26)) - self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26)) - elif len(lines) == 2: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 125), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - elif len(lines) == 3: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26)) - self.draw.text(((240 - tw) / 2, 55), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26)) - tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - elif len(lines) == 4: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 55), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[3], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 160), lines[3], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - - self.show_image() - - return - - - # TODO: Should probably move this to templates.py - def draw_prompt_yes_no(self, lines = [], title = "", bottom = "") -> None: - self.draw_prompt_custom("", "Yes ", "No ", lines, title, bottom) - return - - - # TODO: Should probably move this to templates.py - def draw_prompt_custom(self, a_txt, b_txt, c_txt, lines = [], title = "", bottom = "") -> None: - self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0) - - if len(title) > 0: - tw, th = self.draw.textsize(title, font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 2), title, fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - - if len(bottom) > 0: - tw, th = self.draw.textsize(bottom, font=Fonts.get_font("Assistant-Medium", 18)) - self.draw.text(((240 - tw) / 2, 210), bottom, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18)) - - if len(lines) == 1: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26)) - self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26)) - elif len(lines) == 2: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 125), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - elif len(lines) == 3: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26)) - self.draw.text(((240 - tw) / 2, 20), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26)) - tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - elif len(lines) == 4: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 20), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[3], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 160), lines[3], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - - a_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(a_txt)[0] - self.draw.text((a_x_offset, 39 + 0), a_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25)) - - b_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(b_txt)[0] - self.draw.text((b_x_offset , 39 + 60), b_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25)) - - c_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(c_txt)[0] - self.draw.text((c_x_offset , 39 + 120), c_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25)) - - self.show_image() - - return - def display_blank_screen(self): self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0) self.show_image() - - - - diff --git a/src/seedsigner/gui/screens/psbt_screens.py b/src/seedsigner/gui/screens/psbt_screens.py index 053d47e5..1a6b6506 100644 --- a/src/seedsigner/gui/screens/psbt_screens.py +++ b/src/seedsigner/gui/screens/psbt_screens.py @@ -1,13 +1,14 @@ from dataclasses import dataclass -from lzma import is_check_supported +import math from PIL import Image, ImageDraw, ImageFilter from typing import List +import time from seedsigner.gui.renderer import Renderer from seedsigner.models.threads import BaseThread from .screen import ButtonListScreen, WarningScreen -from ..components import (BtcAmount, Button, Icon, FontAwesomeIconConstants, IconTextLine, FormattedAddress, GUIConstants, Fonts, SeedSignerCustomIconConstants, TextArea, +from ..components import (BtcAmount, Button, Icon, FontAwesomeIconConstants, IconTextLine, FormattedAddress, GUIConstants, Fonts, SeedSignerIconConstants, TextArea, calc_bezier_curve, linear_interp) @@ -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): @@ -97,7 +99,8 @@ class PSBTOverviewScreen(ButtonListScreen): max_inputs_text_width = 0 for input in inputs_column: - tw, th = font.getsize(input) + left, top, right, bottom = font.getbbox(input) + tw, th = right - left, bottom - top max_inputs_text_width = max(tw, max_inputs_text_width) # Given how wide we want our curves on each side to be... @@ -142,13 +145,17 @@ 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") max_destination_text_width = 0 for destination in destination_column: - tw, th = font.getsize(destination) + left, top, right, bottom = font.getbbox(destination) + tw, th = right - left, bottom - top max_destination_text_width = max(tw, max_destination_text_width) return (max_destination_text_width, destination_column) @@ -194,7 +201,8 @@ class PSBTOverviewScreen(ButtonListScreen): input_curves = [] for input in inputs_column: # Calculate right-justified input display - tw, th = font.getsize(input) + left, top, right, bottom = font.getbbox(input) + tw, th = right - left, bottom - top cur_x = inputs_x + max_inputs_text_width - tw draw.text( (cur_x, inputs_y), @@ -439,7 +447,7 @@ class PSBTOverviewScreen(ButtonListScreen): self.renderer.show_image() # No need to CPU limit when running in its own thread? - # time.sleep(0.02) + time.sleep(0.02) @@ -503,7 +511,8 @@ class PSBTMathScreen(ButtonListScreen): body_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, (GUIConstants.BODY_FONT_SIZE)*ssf) fixed_width_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_FONT_NAME, (GUIConstants.BODY_FONT_SIZE + 6)*ssf) - digits_width, digits_height = fixed_width_font.getsize(self.input_amount + "+") + left, top, right, bottom = fixed_width_font.getbbox(self.input_amount + "Q") + digits_width, digits_height = right - left, bottom - top # Draw each line of the equation cur_y = 0 @@ -520,8 +529,10 @@ class PSBTMathScreen(ButtonListScreen): main_zone = display_str[:-6] mid_zone = display_str[-6:-3] end_zone = display_str[-3:] - main_zone_width, th = fixed_width_font.getsize(main_zone) - mid_zone_width, th = fixed_width_font.getsize(end_zone) + left, top, right, bottom = fixed_width_font.getbbox(main_zone) + main_zone_width, th = right - left, bottom - top + left, top, right, bottom = fixed_width_font.getbbox(end_zone) + mid_zone_width, th = right - left, bottom - top draw.text((0, cur_y), text=main_zone, font=fixed_width_font, fill=GUIConstants.BODY_FONT_COLOR) draw.text((main_zone_width + digit_group_spacing, cur_y), text=mid_zone, font=fixed_width_font, fill=secondary_digit_color) draw.text((main_zone_width + digit_group_spacing + mid_zone_width + digit_group_spacing, cur_y), text=end_zone, font=fixed_width_font, fill=tertiary_digit_color) @@ -539,7 +550,7 @@ class PSBTMathScreen(ButtonListScreen): # spend_amount will be zero on self-transfers; only display when there's an # external recipient. if self.num_recipients > 0: - cur_y += int(digits_height * 1.2) + cur_y += digits_height + GUIConstants.BODY_LINE_SPACING * ssf render_amount( cur_y, f"-{self.spend_amount}", @@ -547,16 +558,16 @@ class PSBTMathScreen(ButtonListScreen): info_text=f""" recipient{"s" if self.num_recipients > 1 else ""}""", ) - cur_y += int(digits_height * 1.2) + cur_y += digits_height + GUIConstants.BODY_LINE_SPACING * ssf render_amount( cur_y, f"-{self.fee_amount}", info_text=f""" fee""", ) - cur_y += int(digits_height * 1.2) + 4 * ssf + cur_y += digits_height + GUIConstants.BODY_LINE_SPACING * ssf draw.line((0, cur_y, image.width, cur_y), fill=GUIConstants.BODY_FONT_COLOR, width=1) - cur_y += 8 * ssf + cur_y += GUIConstants.BODY_LINE_SPACING * ssf render_amount( cur_y, @@ -645,18 +656,15 @@ class PSBTChangeDetailsScreen(ButtonListScreen): )) self.components.append(FormattedAddress( - screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING, + screen_y=self.components[-1].screen_y + self.components[-1].height, address=self.address, max_lines=1, )) screen_y = self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING - if self.is_multisig and not self.is_change_addr_verified: - # Adjust the vertical spacing - screen_y -= GUIConstants.COMPONENT_PADDING self.components.append(IconTextLine( - icon_name=SeedSignerCustomIconConstants.FINGERPRINT, - icon_color="blue", + icon_name=SeedSignerIconConstants.FINGERPRINT, + icon_color=GUIConstants.INFO_COLOR, value_text=f"""{"Multisig" if self.is_multisig else self.fingerprint}: {"Change" if self.is_change_derivation_path else "Addr"} #{self.derivation_path_addr_index}""", is_text_centered=False, screen_x=GUIConstants.EDGE_PADDING, @@ -665,8 +673,8 @@ class PSBTChangeDetailsScreen(ButtonListScreen): if self.is_change_addr_verified: self.components.append(IconTextLine( - icon_name=SeedSignerCustomIconConstants.CIRCLE_CHECK, - icon_color="#00dd00", + icon_name=SeedSignerIconConstants.SUCCESS, + icon_color=GUIConstants.SUCCESS_COLOR, value_text="Address verified!", is_text_centered=False, screen_x=GUIConstants.EDGE_PADDING, @@ -675,6 +683,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): @@ -685,7 +744,7 @@ class PSBTFinalizeScreen(ButtonListScreen): icon = Icon( icon_name=FontAwesomeIconConstants.PAPER_PLANE, - icon_color=GUIConstants.SUCCESS_COLOR, + icon_color=GUIConstants.INFO_COLOR, icon_size=GUIConstants.ICON_LARGE_BUTTON_SIZE, screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING ) @@ -696,19 +755,3 @@ class PSBTFinalizeScreen(ButtonListScreen): text="Click to authorize this transaction", screen_y=icon.screen_y + icon.height + GUIConstants.COMPONENT_PADDING )) - - - -@dataclass -class PSBTSelectCoordinatorScreen(ButtonListScreen): - def __post_init__(self): - # Customize defaults - self.title = "Signed PSBT" - self.is_bottom_list = True - super().__post_init__() - - self.components.append(TextArea( - text="Export as a QR code for:", - is_text_centered=True, - screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, - )) diff --git a/src/seedsigner/gui/screens/scan_screens.py b/src/seedsigner/gui/screens/scan_screens.py index 1b29af27..e1af8e15 100644 --- a/src/seedsigner/gui/screens/scan_screens.py +++ b/src/seedsigner/gui/screens/scan_screens.py @@ -1,48 +1,82 @@ import time from dataclasses import dataclass -from typing import List, Tuple +from PIL import Image, ImageDraw from seedsigner.gui import renderer from seedsigner.hardware.buttons import HardwareButtonsConstants from seedsigner.hardware.camera import Camera -from seedsigner.models import DecodeQR, DecodeQRStatus -from seedsigner.models.threads import BaseThread +from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus +from seedsigner.models.threads import BaseThread, ThreadsafeCounter -from .screen import BaseScreen, BaseTopNavScreen, ButtonListScreen -from ..components import BaseComponent, Button, GUIConstants, Fonts, IconButton, TextArea, calc_text_centering +from .screen import BaseScreen +from ..components import GUIConstants, Fonts, SeedSignerIconConstants @dataclass class ScanScreen(BaseScreen): - decoder: DecodeQR = None - instructions_text: str = "Scan a QR code" - resolution: Tuple[int,int] = (480, 480) - framerate: int = 12 - render_rect: Tuple[int,int,int,int] = None + """ + Live preview has to balance three competing threads: + * Camera capturing frames and making them available to read. + * Decoder analyzing frames for QR codes. + * Live preview display writing frames to the screen. + All of this would ideally be rewritten as in C/C++/Rust with python bindings for + vastly improved performance. + + Until then, we have to balance the resources the Pi Zero has to work with. Thus, we + set a modest fps target for the camera: 5fps. At this pace, the decoder and the live + display can more or less keep up with the flow of frames without much wasted effort + in any of the threads. + + Note: performance tuning was targeted for the Pi Zero. + + The resolution (480x480) has not been tweaked in order to guarantee that our + decoding abilities remain as-is. It's possible that more optimizations could be made + here (e.g. higher res w/no performance impact? Lower res w/same decoding but faster + performance? etc). + + Note: This is quite a lot of important tasks for a Screen to be managing; much of + this should probably be refactored into the Controller. + """ + decoder: DecodeQR = None + instructions_text: str = None + resolution: tuple[int,int] = (480, 480) + framerate: int = 6 # TODO: alternate optimization for Pi Zero 2W? + render_rect: tuple[int,int,int,int] = None + + FRAME__ADDED_PART = 1 + FRAME__REPEATED_PART = 2 + FRAME__MISS = 3 def __post_init__(self): from seedsigner.hardware.camera import Camera # Initialize the base class super().__post_init__() + self.instructions_text = "< back | " + self.instructions_text + self.camera = Camera.get_instance() self.camera.start_video_stream_mode(resolution=self.resolution, framerate=self.framerate, format="rgb") + self.frames_decode_status = ThreadsafeCounter() + self.frames_decoded_counter = ThreadsafeCounter() + self.threads.append(ScanScreen.LivePreviewThread( camera=self.camera, decoder=self.decoder, renderer=self.renderer, instructions_text=self.instructions_text, render_rect=self.render_rect, + frame_decode_status=self.frames_decode_status, + frames_decoded_counter=self.frames_decoded_counter, )) class LivePreviewThread(BaseThread): - def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: Tuple[int,int,int,int]): + def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: tuple[int,int,int,int], frame_decode_status: ThreadsafeCounter, frames_decoded_counter: ThreadsafeCounter): self.camera = camera self.decoder = decoder self.renderer = renderer @@ -51,57 +85,159 @@ class ScanScreen(BaseScreen): self.render_rect = render_rect else: self.render_rect = (0, 0, self.renderer.canvas_width, self.renderer.canvas_height) + self.frame_decode_status = frame_decode_status + self.frames_decoded_counter = frames_decoded_counter + self.last_frame_decoded_count = self.frames_decoded_counter.cur_count self.render_width = self.render_rect[2] - self.render_rect[0] self.render_height = self.render_rect[3] - self.render_rect[1] - - print(f"render_width: {self.render_width}") - print(f"render_height: {self.render_height}") + self.decoder_fps = "0.0" super().__init__() def run(self): - from timeit import default_timer as timer - instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE) + + # pre-calculate how big the animated QR percent display can be + left, _, right, _ = instructions_font.getbbox("100%") + progress_text_width = right - left + + start_time = time.time() + num_frames = 0 + debug = False + show_framerate = False # enable for debugging / testing while self.keep_running: - start = timer() frame = self.camera.read_video_stream(as_image=True) if frame is not None: - scan_text = self.instructions_text - if self.decoder and self.decoder.get_percent_complete() > 0 and self.decoder.is_psbt: - scan_text = str(self.decoder.get_percent_complete()) + "% Complete" + num_frames += 1 + cur_time = time.time() + cur_fps = num_frames / (cur_time - start_time) + + scan_text = None + progress_percentage = self.decoder.get_percent_complete() + if progress_percentage == 0: + # We've just started scanning, no results yet + if show_framerate: + scan_text = f"{cur_fps:0.2f} | {self.decoder_fps}" + else: + scan_text = self.instructions_text + + elif debug: + # Special debugging output for animated QRs + scan_text = f"{self.decoder.get_percent_complete()}% | {self.decoder.get_percent_complete(weight_mixed_frames=True)}% (new)" + if show_framerate: + scan_text += f" {cur_fps:0.2f} | {self.decoder_fps}" with self.renderer.lock: if frame.width > self.render_width or frame.height > self.render_height: frame = frame.resize( - (self.render_width, self.render_height) + (self.render_width, self.render_height), + resample=Image.NEAREST # Use nearest neighbor for max speed ) - self.renderer.canvas.paste( - frame, - (self.render_rect[0], self.render_rect[1]) - ) if scan_text: - self.renderer.draw.text( - xy=( - int(self.renderer.canvas_width/2), - self.renderer.canvas_height - GUIConstants.EDGE_PADDING + # Note: shadowed text (adding a 'stroke' outline) can + # significantly slow down the rendering. + # Temp solution: render a slight 1px shadow behind the text + # TODO: Replace the instructions_text with a disappearing + # toast/popup (see: QR Brightness UI)? + draw = ImageDraw.Draw(frame) + draw.text(xy=( + int(self.renderer.canvas_width/2 + 2), + self.renderer.canvas_height - GUIConstants.EDGE_PADDING + 2 + ), + text=scan_text, + fill="black", + font=instructions_font, + anchor="ms") + + # Render the onscreen instructions + draw.text(xy=( + int(self.renderer.canvas_width/2), + self.renderer.canvas_height - GUIConstants.EDGE_PADDING + ), + text=scan_text, + fill=GUIConstants.BODY_FONT_COLOR, + font=instructions_font, + anchor="ms") + + else: + # Render the progress bar + rectangle = Image.new('RGBA', (self.renderer.canvas_width - 2*GUIConstants.EDGE_PADDING, GUIConstants.BUTTON_HEIGHT), (0, 0, 0, 0)) + draw = ImageDraw.Draw(rectangle) + + # Start with a background rounded rectangle, same dims as the buttons + overlay_color = (0, 0, 0, 191) # opacity ranges from 0-255 + draw.rounded_rectangle( + ( + (0, 0), + (rectangle.width, rectangle.height) ), - text=scan_text, - fill=GUIConstants.BODY_FONT_COLOR, - font=instructions_font, - stroke_width=4, - stroke_fill=GUIConstants.BACKGROUND_COLOR, - anchor="ms" + fill=overlay_color, + radius=8, + outline=overlay_color, + width=2, ) - self.renderer.show_image() + progress_bar_thickness = 4 + progress_bar_width = rectangle.width - 2*GUIConstants.EDGE_PADDING - progress_text_width - int(GUIConstants.EDGE_PADDING/2) + progress_bar_xy = ( + (GUIConstants.EDGE_PADDING, int((rectangle.height - progress_bar_thickness) / 2)), + (GUIConstants.EDGE_PADDING + progress_bar_width, int(rectangle.height + progress_bar_thickness) / 2) + ) + draw.rounded_rectangle( + progress_bar_xy, + fill=GUIConstants.INACTIVE_COLOR, + radius=8 + ) - end = timer() - # print(f"{1.0/(end - start)} fps") # Time in seconds, e.g. 5.38091952400282 + progress_percentage = self.decoder.get_percent_complete(weight_mixed_frames=True) + draw.rounded_rectangle( + ( + progress_bar_xy[0], + (GUIConstants.EDGE_PADDING + int(progress_percentage * progress_bar_width / 100.0), progress_bar_xy[1][1]) + ), + fill=GUIConstants.GREEN_INDICATOR_COLOR, + radius=8 + ) + + + draw.text( + xy=(rectangle.width - GUIConstants.EDGE_PADDING, int(rectangle.height / 2)), + text=f"{progress_percentage}%", + # text=f"100%", + fill=GUIConstants.BODY_FONT_COLOR, + font=instructions_font, + anchor="rm", # right-justified, middle + ) + + frame.paste(rectangle, (GUIConstants.EDGE_PADDING, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - rectangle.height), rectangle) + + # Render the dot to indicate successful QR frame read + indicator_size = 10 + self.last_frame_decoded_count = self.frames_decoded_counter.cur_count + status_color_map = { + ScanScreen.FRAME__ADDED_PART: GUIConstants.SUCCESS_COLOR, + ScanScreen.FRAME__REPEATED_PART: GUIConstants.INACTIVE_COLOR, + ScanScreen.FRAME__MISS: None, + } + status_color = status_color_map.get(self.frame_decode_status.cur_count) + if status_color: + # Good! Most recent frame successfully decoded. + # Draw the onscreen indicator dot + draw = ImageDraw.Draw(frame) + draw.ellipse( + ( + (self.renderer.canvas_width - GUIConstants.EDGE_PADDING - indicator_size, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING - indicator_size), + (self.renderer.canvas_width - GUIConstants.EDGE_PADDING, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING) + ), + fill=status_color, + outline="black", + width=1, + ) + + self.renderer.show_image(frame, show_direct=True) - time.sleep(0.05) # turn this up or down to tune performance while decoding psbt if self.camera._video_stream is None: break @@ -112,49 +248,37 @@ class ScanScreen(BaseScreen): Screen. Once interaction starts, the display updates have to be managed in _run(). The live preview is an extra-complex case. """ + num_frames = 0 + start_time = time.time() while True: frame = self.camera.read_video_stream() if frame is not None: status = self.decoder.add_image(frame) + num_frames += 1 + decoder_fps = f"{num_frames / (time.time() - start_time):0.2f}" + self.threads[0].decoder_fps = decoder_fps + if status in (DecodeQRStatus.COMPLETE, DecodeQRStatus.INVALID): self.camera.stop_video_stream_mode() break + + self.frames_decoded_counter.increment() + # Notify the live preview thread how our most recent decode went + if status == DecodeQRStatus.FALSE: + # Did not find anything to decode in the current frame + self.frames_decode_status.set_value(self.FRAME__MISS) + + else: + if status == DecodeQRStatus.PART_COMPLETE: + # We received a valid frame that added new data + self.frames_decode_status.set_value(self.FRAME__ADDED_PART) + + elif status == DecodeQRStatus.PART_EXISTING: + # We received a valid frame, but we've already seen in + self.frames_decode_status.set_value(self.FRAME__REPEATED_PART) - # TODO: KEY_UP gives control to NavBar; use its back arrow to cancel if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_RIGHT) or self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_LEFT): self.camera.stop_video_stream_mode() break - - -@dataclass -class SettingsUpdatedScreen(ButtonListScreen): - config_name: str = None - title: str = "Settings QR" - is_bottom_list: bool = True - - def __post_init__(self): - # Customize defaults - self.button_data = ["Home"] - - super().__post_init__() - - start_y = self.top_nav.height + 20 - if self.config_name: - self.config_name_textarea = TextArea( - text=f'"{self.config_name}"', - is_text_centered=True, - auto_line_break=True, - screen_y=start_y - ) - self.components.append(self.config_name_textarea) - start_y = self.config_name_textarea.screen_y + 50 - - self.components.append(TextArea( - text="Settings imported successfully!", - is_text_centered=True, - auto_line_break=True, - screen_y=start_y - )) - diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py index debaae45..a77997ab 100644 --- a/src/seedsigner/gui/screens/screen.py +++ b/src/seedsigner/gui/screens/screen.py @@ -3,17 +3,16 @@ import time from dataclasses import dataclass from PIL import Image, ImageDraw, ImageColor from typing import Any, List, Tuple + +from seedsigner.gui.components import (GUIConstants, + BaseComponent, Button, Icon, IconButton, LargeIconButton, + SeedSignerIconConstants, TopNav, TextArea, load_image) from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay from seedsigner.gui.renderer import Renderer - -from seedsigner.models.threads import BaseThread -from seedsigner.models.encode_qr import EncodeQR -from seedsigner.models.settings import Settings, SettingsConstants - -from ..components import (FontAwesomeIconConstants, GUIConstants, BaseComponent, Button, Icon, IconButton, LargeIconButton, SeedSignerCustomIconConstants, TopNav, - TextArea, load_image) - from seedsigner.hardware.buttons import HardwareButtonsConstants, HardwareButtons +from seedsigner.models.encode_qr import BaseQrEncoder +from seedsigner.models.settings import SettingsConstants +from seedsigner.models.threads import BaseThread, ThreadsafeCounter # Must be huge numbers to avoid conflicting with the selected_button returned by the @@ -266,6 +265,9 @@ class ButtonListScreen(BaseTopNavScreen): Button_cls = Button checked_buttons: List[int] = None + # Enables returning w/buttons rendered at the same place + scroll_y_initial_offset: int = None + def __post_init__(self): super().__post_init__() @@ -316,6 +318,7 @@ class ButtonListScreen(BaseTopNavScreen): right_icon_name=right_icon_name, screen_x=GUIConstants.EDGE_PADDING, screen_y=button_list_y + i * (button_height + GUIConstants.LIST_ITEM_PADDING), + scroll_y=self.scroll_y_initial_offset if self.scroll_y_initial_offset is not None else 0, width=self.canvas_width - (2 * GUIConstants.EDGE_PADDING), height=button_height, is_text_centered=self.is_button_text_centered, @@ -331,7 +334,7 @@ class ButtonListScreen(BaseTopNavScreen): if self.has_scroll_arrows: self.arrow_half_width = 10 - self.cur_scroll_y = 0 + self.cur_scroll_y = self.scroll_y_initial_offset if self.scroll_y_initial_offset is not None else 0 self.up_arrow_img = Image.new("RGBA", size=(2 * self.arrow_half_width, 8), color="black") self.up_arrow_img_y = self.top_nav.height - 12 arrow_draw = ImageDraw.Draw(self.up_arrow_img) @@ -347,10 +350,6 @@ class ButtonListScreen(BaseTopNavScreen): cur_selected_button = self.buttons[self.selected_button] cur_selected_button.is_selected = True - if self.has_scroll_arrows: - frame_scroll = self.buttons[0].screen_y - cur_selected_button.screen_y - for button in self.buttons: - button.scroll_y -= frame_scroll def _render(self): @@ -659,54 +658,186 @@ class LargeButtonScreen(BaseTopNavScreen): @dataclass class QRDisplayScreen(BaseScreen): - qr_encoder: EncodeQR = None + qr_encoder: BaseQrEncoder = None + + class QRDisplayThread(BaseThread): + def __init__(self, qr_encoder: BaseQrEncoder, qr_brightness: ThreadsafeCounter, renderer: Renderer, + tips_start_time: ThreadsafeCounter): + super().__init__() + self.qr_encoder = qr_encoder + self.qr_brightness = qr_brightness + self.renderer = renderer + self.tips_start_time = tips_start_time + + + 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. + + # Instantiate a temp Image and ImageDraw object to draw on + rectangle_width = image.width + rectangle_height = GUIConstants.COMPONENT_PADDING * 2 + GUIConstants.BODY_FONT_SIZE * 2 + GUIConstants.BODY_LINE_SPACING + rectangle = Image.new('RGBA', (rectangle_width, rectangle_height), (0, 0, 0, 0)) + img_draw = ImageDraw.Draw(rectangle) + + overlay_opacity = 224 + + # Create a semi-transparent background for the overlay, rounded edges, w/a 1-pixel gap from the edges + img_draw.rounded_rectangle((1, 0, rectangle_width - 2, rectangle_height - 1), radius=8, fill=(0, 0, 0, overlay_opacity)) + + chevron_up_icon = Icon( + image_draw=img_draw, + canvas=rectangle, + screen_x=GUIConstants.EDGE_PADDING*2 + 1, + screen_y=GUIConstants.COMPONENT_PADDING + 4, # +4 fudge factor to account for where the chevron is drawn relative to baseline + icon_name=SeedSignerIconConstants.CHEVRON_UP, + icon_size=GUIConstants.BODY_FONT_SIZE, + ) + chevron_up_icon.render() + + chevron_down_icon = Icon( + image_draw=img_draw, + canvas=rectangle, + screen_x=chevron_up_icon.screen_x, + screen_y=chevron_up_icon.screen_y + chevron_up_icon.icon_size + GUIConstants.BODY_LINE_SPACING, + icon_name=SeedSignerIconConstants.CHEVRON_DOWN, + icon_size=chevron_up_icon.icon_size, + ) + chevron_down_icon.render() + + TextArea( + image_draw=img_draw, + canvas=rectangle, + text="Brighter", + font_size=GUIConstants.BODY_FONT_SIZE, + font_name=GUIConstants.BUTTON_FONT_NAME, + background_color=(0, 0, 0, overlay_opacity), + edge_padding=0, + is_text_centered=False, + auto_line_break=False, + width=int(rectangle_width/2), + screen_x=chevron_up_icon.screen_x + GUIConstants.ICON_INLINE_FONT_SIZE, + screen_y=chevron_up_icon.screen_y - 2, # -2 to account for Icon's positioning + allow_text_overflow=False + ).render() + + TextArea( + image_draw=img_draw, + canvas=rectangle, + text="Darker", + font_size=GUIConstants.BODY_FONT_SIZE, + font_name=GUIConstants.BUTTON_FONT_NAME, + background_color=(0, 0, 0, overlay_opacity), + edge_padding=0, + is_text_centered=False, + auto_line_break=False, + width=int(rectangle_width/2), + screen_x=chevron_down_icon.screen_x + GUIConstants.ICON_INLINE_FONT_SIZE, + screen_y=chevron_down_icon.screen_y - 2, # -2 to account for Icon's positioning + allow_text_overflow=False + ).render() + + # Write our temp Image onto the main image + image.paste(rectangle, (0, image.height - rectangle_height - 1), rectangle) + + + def run(self): + from seedsigner.models.settings import Settings + settings = Settings.get_instance() + cur_brightness_setting = settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS_TIPS) + 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 + + # Display the brightness tips toast + duration = 10 ** 9 * 1.2 # 1.2 seconds + 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) + + # Target n held frames per second before rendering next QR image + time.sleep(5 / 30.0) + + + def __post_init__(self): + from seedsigner.models.settings import Settings + super().__post_init__() + + # Shared coordination var so the display thread can detect success + settings = Settings.get_instance() + self.qr_brightness = ThreadsafeCounter( + initial_value=settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS)) + self.tips_start_time = ThreadsafeCounter(initial_value=time.time_ns()) + + self.threads.append(QRDisplayScreen.QRDisplayThread( + qr_encoder=self.qr_encoder, + qr_brightness=self.qr_brightness, + renderer=self.renderer, + tips_start_time=self.tips_start_time + )) + def _run(self): from seedsigner.models.settings import Settings - settings = Settings.get_instance() - cur_brightness = settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS) - # Loop whether the QR is a single frame or animated; each loop might adjust - # brightness setting. while True: - ret = self._run_callback() - if ret is not None: - return ret - - # convert the cur_brightness integer (31-255) into hex triplets - hex_color = (hex(cur_brightness).split('x')[1]) * 3 - image = self.qr_encoder.next_part_image(240,240, border=2, background_color=hex_color) - self.renderer.show_image(image) - - # Target n held frames per second before rendering next QR image - time.sleep(5/30.0) - - if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_DOWN): + user_input = self.hw_inputs.wait_for( + [ + HardwareButtonsConstants.KEY_UP, + HardwareButtonsConstants.KEY_DOWN, + HardwareButtonsConstants.KEY_LEFT, + HardwareButtonsConstants.KEY_RIGHT, + ] + HardwareButtonsConstants.KEYS__ANYCLICK, + check_release=True, + release_keys=HardwareButtonsConstants.KEYS__ANYCLICK + ) + if user_input == HardwareButtonsConstants.KEY_DOWN: # Reduce QR code background brightness - cur_brightness = max(31, cur_brightness - 31) + self.qr_brightness.set_value(max(31, self.qr_brightness.cur_count - 31)) + self.tips_start_time.set_value(time.time_ns()) - elif self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_UP): + elif user_input == HardwareButtonsConstants.KEY_UP: # Incrase QR code background brightness - cur_brightness = min(cur_brightness + 31, 255) + self.qr_brightness.set_value(min(self.qr_brightness.cur_count + 31, 255)) + self.tips_start_time.set_value(time.time_ns()) - elif self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_RIGHT): + else: + # Any other input exits the screen + self.threads[-1].stop() + while self.threads[-1].is_alive(): + time.sleep(0.01) break - - settings.set_value(SettingsConstants.SETTING__QR_BRIGHTNESS, cur_brightness) - - # TODO: handle left as BACK + Settings.get_instance().set_value(SettingsConstants.SETTING__QR_BRIGHTNESS, self.qr_brightness.cur_count) @dataclass class LargeIconStatusScreen(ButtonListScreen): title: str = "Success!" - status_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_CHECK + status_icon_name: str = SeedSignerIconConstants.SUCCESS status_icon_size: int = GUIConstants.ICON_PRIMARY_SCREEN_SIZE status_color: str = GUIConstants.SUCCESS_COLOR status_headline: str = "Success!" # The colored text under the large icon text: str = "" # The body text of the screen + text_edge_padding: int = GUIConstants.EDGE_PADDING button_data: list = None allow_text_overflow: bool = False @@ -726,7 +857,7 @@ class LargeIconStatusScreen(ButtonListScreen): self.status_icon.screen_x = int((self.canvas_width - self.status_icon.width) / 2) self.components.append(self.status_icon) - next_y = self.status_icon.screen_y + self.status_icon.height + 4 + next_y = self.status_icon.screen_y + self.status_icon.height + int(GUIConstants.COMPONENT_PADDING/2) if self.status_headline: self.warning_headline_textarea = TextArea( text=self.status_headline, @@ -742,6 +873,7 @@ class LargeIconStatusScreen(ButtonListScreen): height=self.buttons[0].screen_y - next_y, text=self.text, width=self.canvas_width, + edge_padding=self.text_edge_padding, # Don't render all the way up to the far left/right edges screen_y=next_y, allow_text_overflow=self.allow_text_overflow, )) @@ -815,6 +947,7 @@ class WarningEdgesThread(BaseThread): @dataclass class WarningEdgesMixin: status_color: str = GUIConstants.WARNING_COLOR + text_edge_padding: int = 2 * GUIConstants.EDGE_PADDING def __post_init__(self): super().__post_init__() @@ -826,7 +959,7 @@ class WarningEdgesMixin: @dataclass class WarningScreen(WarningEdgesMixin, LargeIconStatusScreen): title: str = "Caution" - status_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION + status_icon_name: str = SeedSignerIconConstants.WARNING status_color: str = "yellow" status_headline: str = "Privacy Leak!" # The colored text under the alert icon @@ -875,6 +1008,21 @@ class PowerOffScreen(BaseTopNavScreen): +@dataclass +class PowerOffNotRequiredScreen(BaseTopNavScreen): + def __post_init__(self): + self.title = "Just Unplug It" + self.show_back_button = True + super().__post_init__() + + self.components.append(TextArea( + text="It is safe to disconnect power at any time.", + screen_y=self.top_nav.height, + height=self.canvas_height - self.top_nav.height, + )) + + + @dataclass class KeyboardScreen(BaseTopNavScreen): """ @@ -922,7 +1070,7 @@ class KeyboardScreen(BaseTopNavScreen): # Render the right button panel (only has a Key3 "Save" button) self.save_button = IconButton( - icon_name=FontAwesomeIconConstants.SOLID_CIRCLE_CHECK, + icon_name=SeedSignerIconConstants.CHECK, icon_color=GUIConstants.SUCCESS_COLOR, width=right_panel_buttons_width, screen_x=hw_button_x, @@ -1003,14 +1151,17 @@ class KeyboardScreen(BaseTopNavScreen): elif self.show_save_button and input == HardwareButtonsConstants.KEY3: # Save! + if len(self.user_input) == 0: + # Don't try to submit zero input + continue + # First show the save button reacting to the click self.save_button.is_selected = True self.save_button.render() self.renderer.show_image() # Then return the input to the View - if len(self.user_input) > 0: - return self.user_input.strip() + return self.user_input.strip() # Process normal input if input in [HardwareButtonsConstants.KEY_UP, HardwareButtonsConstants.KEY_DOWN] and self.top_nav.is_selected: @@ -1081,3 +1232,12 @@ class KeyboardScreen(BaseTopNavScreen): self.title = f"Roll {self.cursor_position + 1}" """ return False + + + +@dataclass +class MainMenuScreen(LargeButtonScreen): + # Override LargeButtonScreen defaults + title_font_size: int = 26 + show_back_button: bool = False + show_power_button: bool = True diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 79db667b..64fb7ea9 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -1,26 +1,23 @@ import math +import logging import time from dataclasses import dataclass -from typing import List, Tuple +from typing import List from PIL import Image, ImageDraw, ImageFilter from seedsigner.gui.renderer import Renderer from seedsigner.helpers.qr import QR -from seedsigner.models.qr_type import QRType from seedsigner.models.threads import BaseThread, ThreadsafeCounter -from seedsigner.models.seed import Seed -from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition - from .screen import RET_CODE__BACK_BUTTON, BaseScreen, BaseTopNavScreen, ButtonListScreen, KeyboardScreen, WarningEdgesMixin from ..components import (Button, FontAwesomeIconConstants, Fonts, FormattedAddress, IconButton, - IconTextLine, SeedSignerCustomIconConstants, TextArea, GUIConstants, - calc_text_centering) + IconTextLine, SeedSignerIconConstants, TextArea, GUIConstants, reflow_text_into_pages) from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay from seedsigner.hardware.buttons import HardwareButtons, HardwareButtonsConstants +logger = logging.getLogger(__name__) @dataclass @@ -379,7 +376,8 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen): self.keyboard.render_keys() - elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN: + elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN \ + or input in (Keyboard.ENTER_TOP, Keyboard.ENTER_BOTTOM): if ret_val in self.possible_alphabet: # Live joystick movement; haven't locked this new letter in yet. # Replace the last letter w/the currently selected one. But don't @@ -420,8 +418,8 @@ class SeedFinalizeScreen(ButtonListScreen): super().__post_init__() self.fingerprint_icontl = IconTextLine( - icon_name=SeedSignerCustomIconConstants.FINGERPRINT, - icon_color="blue", + icon_name=SeedSignerIconConstants.FINGERPRINT, + icon_color=GUIConstants.INFO_COLOR, icon_size=GUIConstants.ICON_FONT_SIZE + 12, label_text="fingerprint", value_text=self.fingerprint, @@ -436,15 +434,16 @@ class SeedFinalizeScreen(ButtonListScreen): @dataclass class SeedOptionsScreen(ButtonListScreen): # Customize defaults - is_bottom_list: bool = True fingerprint: str = None has_passphrase: bool = False def __post_init__(self): - self.top_nav_icon_name = SeedSignerCustomIconConstants.FINGERPRINT - self.top_nav_icon_color = "blue" + self.top_nav_icon_name = SeedSignerIconConstants.FINGERPRINT + self.top_nav_icon_color = GUIConstants.INFO_COLOR self.title = self.fingerprint self.is_button_text_centered = False + self.is_bottom_list = True + super().__post_init__() @@ -500,7 +499,7 @@ class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen): for index, word in enumerate(self.words): draw.rounded_rectangle( (number_box_x, number_box_y, number_box_x + number_box_width, number_box_y + number_box_height), - fill="#202020", + fill=GUIConstants.BUTTON_BACKGROUND_COLOR, radius=5 * supersampling_factor ) baseline_y = number_box_y + number_box_height - int((number_box_height - number_height)/2) @@ -508,7 +507,7 @@ class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen): (number_box_x + int(number_box_width/2), baseline_y), font=number_font, text=str(self.page_index * words_per_page + index + 1), - fill="#0084ff", + fill=GUIConstants.INFO_COLOR, anchor="ms" # Middle (centered), baSeline ) @@ -530,6 +529,22 @@ class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen): +@dataclass +class SeedBIP85SelectChildIndexScreen(KeyboardScreen): + def __post_init__(self): + self.title = "BIP-85 Index" + self.user_input = "" + + # Specify the keys in the keyboard + self.rows = 3 + self.cols = 5 + self.keys_charset = "0123456789" + self.show_save_button = True + + super().__post_init__() + + + @dataclass class SeedWordsBackupTestPromptScreen(ButtonListScreen): def __post_init__(self): @@ -582,8 +597,8 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen): # Set up the fingerprint and passphrase displays self.fingerprint_line = IconTextLine( - icon_name=SeedSignerCustomIconConstants.FINGERPRINT, - icon_color="blue", + icon_name=SeedSignerIconConstants.FINGERPRINT, + icon_color=GUIConstants.INFO_COLOR, label_text="Fingerprint", value_text=self.fingerprint, screen_x=GUIConstants.COMPONENT_PADDING, @@ -592,7 +607,8 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen): self.components.append(self.fingerprint_line) self.derivation_line = IconTextLine( - icon_name=SeedSignerCustomIconConstants.PATH, + icon_name=SeedSignerIconConstants.DERIVATION, + icon_color=GUIConstants.INFO_COLOR, label_text="Derivation", value_text=self.derivation_path, screen_x=GUIConstants.COMPONENT_PADDING, @@ -602,6 +618,7 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen): self.xpub_line = IconTextLine( icon_name=FontAwesomeIconConstants.X, + icon_color=GUIConstants.INFO_COLOR, label_text="Xpub", value_text=f"{self.xpub[:18]}...", font_name=GUIConstants.FIXED_WIDTH_FONT_NAME, @@ -615,13 +632,14 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen): @dataclass class SeedAddPassphraseScreen(BaseTopNavScreen): - title: str = "Add Passphrase" + title: str = "BIP-39 Passphrase" passphrase: str = "" KEYBOARD__LOWERCASE_BUTTON_TEXT = "abc" KEYBOARD__UPPERCASE_BUTTON_TEXT = "ABC" KEYBOARD__DIGITS_BUTTON_TEXT = "123" - KEYBOARD__SYMBOLS_BUTTON_TEXT = "!@#" + KEYBOARD__SYMBOLS_1_BUTTON_TEXT = "!@#" + KEYBOARD__SYMBOLS_2_BUTTON_TEXT = "*[]" def __post_init__(self): @@ -630,7 +648,14 @@ class SeedAddPassphraseScreen(BaseTopNavScreen): keys_lower = "abcdefghijklmnopqrstuvwxyz" keys_upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" keys_number = "0123456789" - keys_symbol = "!\"#$%&'()*+,=./;:<>?@[]|-_`~" + + # Present the most common/puncutation-related symbols & the most human-friendly + # symbols first (limited to 18 chars). + keys_symbol_1 = """!@#$%&();:,.-+='"?""" + + # Isolate the more math-oriented or just uncommon symbols + keys_symbol_2 = """^*[]{}_\\|<>/`~""" + # Set up the keyboard params self.right_panel_buttons_width = 56 @@ -701,11 +726,11 @@ class SeedAddPassphraseScreen(BaseTopNavScreen): render_now=False ) - self.keyboard_symbols = Keyboard( + self.keyboard_symbols_1 = Keyboard( draw=self.renderer.draw, - charset=keys_symbol, + charset=keys_symbol_1, rows=4, - cols=max_cols, + cols=6, rect=( GUIConstants.COMPONENT_PADDING, keyboard_start_y, @@ -713,7 +738,28 @@ class SeedAddPassphraseScreen(BaseTopNavScreen): self.canvas_height - GUIConstants.EDGE_PADDING ), additional_keys=[ - Keyboard.KEY_SPACE_4, + Keyboard.KEY_SPACE_2, + Keyboard.KEY_CURSOR_LEFT, + Keyboard.KEY_CURSOR_RIGHT, + Keyboard.KEY_BACKSPACE + ], + auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT], + render_now=False + ) + + self.keyboard_symbols_2 = Keyboard( + draw=self.renderer.draw, + charset=keys_symbol_2, + rows=4, + cols=6, + rect=( + GUIConstants.COMPONENT_PADDING, + keyboard_start_y, + self.canvas_width - GUIConstants.COMPONENT_PADDING - self.right_panel_buttons_width, + self.canvas_height - GUIConstants.EDGE_PADDING + ), + additional_keys=[ + Keyboard.KEY_SPACE_2, Keyboard.KEY_CURSOR_LEFT, Keyboard.KEY_CURSOR_RIGHT, Keyboard.KEY_BACKSPACE @@ -762,7 +808,7 @@ class SeedAddPassphraseScreen(BaseTopNavScreen): ) self.hw_button3 = IconButton( - icon_name=FontAwesomeIconConstants.SOLID_CIRCLE_CHECK, + icon_name=SeedSignerIconConstants.CHECK, icon_color=GUIConstants.SUCCESS_COLOR, width=self.right_panel_buttons_width, screen_x=hw_button_x, @@ -800,19 +846,18 @@ class SeedAddPassphraseScreen(BaseTopNavScreen): keyboard_swap = False # Check our two possible exit conditions + # TODO: note the unusual return value, consider refactoring to a Response object in the future if input == HardwareButtonsConstants.KEY3: # Save! # First light up key3 self.hw_button3.is_selected = True self.hw_button3.render() self.renderer.show_image() - - if len(self.passphrase) > 0: - return self.passphrase.strip() + return dict(passphrase=self.passphrase) elif input == HardwareButtonsConstants.KEY_PRESS and self.top_nav.is_selected: # Back button clicked - return self.top_nav.selected_button + return dict(passphrase=self.passphrase, is_back_button=True) # Check for keyboard swaps if input == HardwareButtonsConstants.KEY1: @@ -823,8 +868,10 @@ class SeedAddPassphraseScreen(BaseTopNavScreen): # Return to the same button2 keyboard, if applicable if cur_keyboard == self.keyboard_digits: cur_button2_text = self.KEYBOARD__DIGITS_BUTTON_TEXT - elif cur_keyboard == self.keyboard_symbols: - cur_button2_text = self.KEYBOARD__SYMBOLS_BUTTON_TEXT + elif cur_keyboard == self.keyboard_symbols_1: + cur_button2_text = self.KEYBOARD__SYMBOLS_1_BUTTON_TEXT + elif cur_keyboard == self.keyboard_symbols_2: + cur_button2_text = self.KEYBOARD__SYMBOLS_2_BUTTON_TEXT if cur_button1_text == self.KEYBOARD__LOWERCASE_BUTTON_TEXT: self.keyboard_abc.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) @@ -861,10 +908,15 @@ class SeedAddPassphraseScreen(BaseTopNavScreen): self.keyboard_digits.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) cur_keyboard = self.keyboard_digits cur_keyboard.render_keys() - cur_button2_text = self.KEYBOARD__SYMBOLS_BUTTON_TEXT - else: - self.keyboard_symbols.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) - cur_keyboard = self.keyboard_symbols + cur_button2_text = self.KEYBOARD__SYMBOLS_1_BUTTON_TEXT + elif cur_button2_text == self.KEYBOARD__SYMBOLS_1_BUTTON_TEXT: + self.keyboard_symbols_1.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) + cur_keyboard = self.keyboard_symbols_1 + cur_keyboard.render_keys() + cur_button2_text = self.KEYBOARD__SYMBOLS_2_BUTTON_TEXT + elif cur_button2_text == self.KEYBOARD__SYMBOLS_2_BUTTON_TEXT: + self.keyboard_symbols_2.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) + cur_keyboard = self.keyboard_symbols_2 cur_keyboard.render_keys() cur_button2_text = self.KEYBOARD__DIGITS_BUTTON_TEXT cur_keyboard.render_keys() @@ -973,14 +1025,16 @@ class SeedReviewPassphraseScreen(ButtonListScreen): super().__post_init__() self.components.append(IconTextLine( - icon_name=SeedSignerCustomIconConstants.FINGERPRINT, - icon_color="blue", + icon_name=SeedSignerIconConstants.FINGERPRINT, + icon_color=GUIConstants.INFO_COLOR, label_text="changes fingerprint", value_text=f"{self.fingerprint_without} >> {self.fingerprint_with}", is_text_centered=True, screen_y = self.buttons[0].screen_y - GUIConstants.COMPONENT_PADDING - int(GUIConstants.BODY_FONT_SIZE*2.5) )) + if self.passphrase != self.passphrase.strip() or " " in self.passphrase: + self.passphrase = self.passphrase.replace(" ", "\u2589") available_height = self.components[-1].screen_y - self.top_nav.height + GUIConstants.COMPONENT_PADDING max_font_size = GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 8 min_font_size = GUIConstants.TOP_NAV_TITLE_FONT_SIZE - 4 @@ -992,7 +1046,8 @@ class SeedReviewPassphraseScreen(ButtonListScreen): if found_solution: break font = Fonts.get_font(font_name=GUIConstants.FIXED_WIDTH_FONT_NAME, size=font_size) - char_width, char_height = font.getsize("X") + left, top, right, bottom = font.getbbox("X") + char_width, char_height = right - left, bottom - top for num_lines in range(1, max_lines+1): # Break the passphrase into n lines chars_per_line = math.ceil(len(self.passphrase) / num_lines) @@ -1017,6 +1072,7 @@ class SeedReviewPassphraseScreen(ButtonListScreen): font_size=font_size, is_text_centered=True, screen_y=screen_y, + allow_text_overflow=True )) screen_y += char_height + 2 @@ -1289,7 +1345,7 @@ class AddressVerificationSigTypeScreen(ButtonListScreen): @dataclass -class SeedSingleSigAddressVerificationSelectSeedScreen(ButtonListScreen): +class SeedSelectSeedScreen(ButtonListScreen): text: str = "" def __post_init__(self): @@ -1358,9 +1414,12 @@ class SeedAddressVerificationScreen(ButtonListScreen): def _run_callback(self): # Exit the screen on success via a non-None value - print(f"verified_index: {self.verified_index.cur_count}") + logger.info(f"verified_index: {self.verified_index.cur_count}") if self.verified_index.cur_count is not None: - print("Screen callback returning success!") + logger.info("Screen callback returning success!") + self.threads[-1].stop() + while self.threads[-1].is_alive(): + time.sleep(0.01) return 1 @@ -1374,10 +1433,12 @@ class SeedAddressVerificationScreen(ButtonListScreen): def run(self): - font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BODY_FONT_SIZE) while self.keep_running: if self.verified_index.cur_count is not None: - # Have to trigger a hw_input event to break the Screen out of the wait_for loop + # This thread will detect the success state while its parent Screen + # holds in its `wait_for`. Have to trigger a hw_input event to break + # the Screen._run out of the `wait_for` state. The Screen will then + # call its `_run_callback` and detect the success state and exit. HardwareButtons.get_instance().trigger_override(force_release=True) return @@ -1439,3 +1500,79 @@ class MultisigWalletDescriptorScreen(ButtonListScreen): auto_line_break=True, allow_text_overflow=True, )) + + + +@dataclass +class SeedSignMessageConfirmMessageScreen(ButtonListScreen): + page_num: int = None + + def __post_init__(self): + from seedsigner.controller import Controller + renderer = Renderer.get_instance() + start_y = GUIConstants.TOP_NAV_HEIGHT + GUIConstants.COMPONENT_PADDING + end_y = renderer.canvas_height - GUIConstants.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( + text=self.sign_message_data["message"], + width=renderer.canvas_width - 2*GUIConstants.EDGE_PADDING, + height=message_height, + allow_text_overflow=True, + ) + self.sign_message_data["paged_message"] = paged + + if self.page_num >= len(self.sign_message_data["paged_message"]): + raise Exception("Bug in paged_message calculation") + + if len(self.sign_message_data["paged_message"]) == 1: + self.title = "Review Message" + else: + self.title = f"""Message (pt {self.page_num + 1}/{len(self.sign_message_data["paged_message"])})""" + self.is_bottom_list = True + self.is_button_text_centered = True + self.button_data = ["Next"] + super().__post_init__() + + message_display = TextArea( + text=self.sign_message_data["paged_message"][self.page_num], + is_text_centered=False, + allow_text_overflow=True, + screen_y=start_y, + ) + self.components.append(message_display) + + + +@dataclass +class SeedSignMessageConfirmAddressScreen(ButtonListScreen): + derivation_path: str = None + address: str = None + + def __post_init__(self): + self.title = "Confirm Address" + self.is_bottom_list = True + self.is_button_text_centered = True + self.button_data = ["Sign Message"] + super().__post_init__() + + derivation_path_display = IconTextLine( + icon_name=SeedSignerIconConstants.DERIVATION, + icon_color=GUIConstants.INFO_COLOR, + label_text="derivation path", + value_text=self.derivation_path, + is_text_centered=True, + screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, + ) + self.components.append(derivation_path_display) + + address_display = FormattedAddress( + address=self.address, + max_lines=3, + screen_y=derivation_path_display.screen_y + derivation_path_display.height + 2*GUIConstants.COMPONENT_PADDING, + ) + self.components.append(address_display) diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py index c09f1e27..55fa5dd0 100644 --- a/src/seedsigner/gui/screens/settings_screens.py +++ b/src/seedsigner/gui/screens/settings_screens.py @@ -296,3 +296,36 @@ class DonateScreen(BaseTopNavScreen): supersampling_factor=1, screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING )) + + + +@dataclass +class SettingsQRConfirmationScreen(ButtonListScreen): + config_name: str = None + title: str = "Settings QR" + status_message: str = "Settings updated..." + is_bottom_list: bool = True + + def __post_init__(self): + # Customize defaults + self.button_data = ["Home"] + self.show_back_button = False + super().__post_init__() + + start_y = self.top_nav.height + 20 + if self.config_name: + self.config_name_textarea = TextArea( + text=f'"{self.config_name}"', + is_text_centered=True, + auto_line_break=True, + screen_y=start_y + ) + self.components.append(self.config_name_textarea) + start_y = self.config_name_textarea.screen_y + 50 + + self.components.append(TextArea( + text=self.status_message, + is_text_centered=True, + auto_line_break=True, + screen_y=start_y + )) diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py index 229146d5..13fa33be 100644 --- a/src/seedsigner/gui/screens/tools_screens.py +++ b/src/seedsigner/gui/screens/tools_screens.py @@ -1,11 +1,14 @@ -from dataclasses import dataclass -from PIL.Image import Image -from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay -from seedsigner.hardware.camera import Camera -from seedsigner.gui.components import FontAwesomeIconConstants, Fonts, GUIConstants, IconTextLine, SeedSignerCustomIconConstants, TextArea +import time -from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen, BaseTopNavScreen, ButtonListScreen, KeyboardScreen +from dataclasses import dataclass +from typing import Any +from PIL.Image import Image +from seedsigner.hardware.camera import Camera +from seedsigner.gui.components import FontAwesomeIconConstants, Fonts, GUIConstants, IconTextLine, SeedSignerIconConstants, TextArea + +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen, ButtonListScreen, KeyboardScreen from seedsigner.hardware.buttons import HardwareButtonsConstants +from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition @@ -19,7 +22,7 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen): super().__post_init__() self.camera = Camera.get_instance() - self.camera.start_video_stream_mode(resolution=(240, 240), framerate=24, format="rgb") + self.camera.start_video_stream_mode(resolution=(self.canvas_width, self.canvas_height), framerate=24, format="rgb") def _run(self): @@ -29,27 +32,7 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen): instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE) while True: - frame = self.camera.read_video_stream(as_image=True) - if frame is not None: - self.renderer.canvas.paste(frame) - - self.renderer.draw.text( - xy=( - int(self.renderer.canvas_width/2), - self.renderer.canvas_height - GUIConstants.EDGE_PADDING - ), - text="< back | click joystick", - fill=GUIConstants.BODY_FONT_COLOR, - font=instructions_font, - stroke_width=4, - stroke_fill=GUIConstants.BACKGROUND_COLOR, - anchor="ms" - ) - self.renderer.show_image() - - if len(preview_images) < max_entropy_frames: - preview_images.append(frame) - + # Check for BACK button press if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_LEFT): # Have to manually update last input time since we're not in a wait_for loop self.hw_inputs.update_last_input_time() @@ -57,7 +40,15 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen): self.camera.stop_video_stream_mode() return RET_CODE__BACK_BUTTON - elif self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_PRESS): + frame = self.camera.read_video_stream(as_image=True) + + if frame is None: + # Camera probably isn't ready yet + time.sleep(0.01) + continue + + # Check for joystick click to take final entropy image + if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_PRESS): # 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() @@ -80,6 +71,29 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen): return preview_images + # If we're still here, it's just another preview frame loop + self.renderer.canvas.paste(frame) + + self.renderer.draw.text( + xy=( + int(self.renderer.canvas_width/2), + self.renderer.canvas_height - GUIConstants.EDGE_PADDING + ), + text="< back | click joystick", + fill=GUIConstants.BODY_FONT_COLOR, + font=instructions_font, + stroke_width=4, + stroke_fill=GUIConstants.BACKGROUND_COLOR, + anchor="ms" + ) + self.renderer.show_image() + + if len(preview_images) == max_entropy_frames: + # Keep a moving window of the last n preview frames; pop the oldest + # before we add the currest frame. + preview_images.pop(0) + preview_images.append(frame) + @dataclass @@ -163,7 +177,7 @@ class ToolsCalcFinalWordFinalizePromptScreen(ButtonListScreen): self.components.append(TextArea( text=f"The {self.mnemonic_length}th word is built from {self.num_entropy_bits} more entropy bits plus auto-calculated checksum.", - screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, + screen_y=self.top_nav.height + int(GUIConstants.COMPONENT_PADDING/2), )) @@ -237,24 +251,27 @@ class ToolsCalcFinalWordScreen(ButtonListScreen): self.components.append(TextArea( text=f"""Your input: \"{selection_text}\"""", - screen_y=self.top_nav.height, + screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING - 2, # Nudge to last line doesn't get too close to "Next" button + height_ignores_below_baseline=True, # Keep the next line (bits display) snugged up, regardless of text rendering below the baseline )) # ...and that entropy's associated 11 bits - screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING - self.components.append(TextArea( + screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING + first_bits_line = TextArea( text=keeper_selected_bits, font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, font_size=bit_font_size, edge_padding=0, screen_x=bit_display_x, screen_y=screen_y, - height=bit_font_height, is_text_centered=False, - )) + ) + self.components.append(first_bits_line) # Render the least significant bits that will be replaced by the checksum in a # de-emphasized font color. + if "_" in discard_selected_bits: + screen_y += int(first_bits_line.height/2) # center the underscores vertically like hypens self.components.append(TextArea( text=discard_selected_bits, font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, @@ -263,7 +280,6 @@ class ToolsCalcFinalWordScreen(ButtonListScreen): edge_padding=0, screen_x=checksum_x, screen_y=screen_y, - height=bit_font_height, is_text_centered=False, )) @@ -271,7 +287,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen): self.components.append(TextArea( text="Checksum", edge_padding=0, - screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING, + screen_y=first_bits_line.screen_y + first_bits_line.height + 2*GUIConstants.COMPONENT_PADDING, )) # ...and its actual bits. Prepend spacers to keep vertical alignment @@ -287,8 +303,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen): font_size=bit_font_size, edge_padding=0, screen_x=bit_display_x, - screen_y=screen_y, - height=bit_font_height, + screen_y=screen_y + int(first_bits_line.height/2), # center the underscores vertically like hypens is_text_centered=False, )) @@ -308,6 +323,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen): self.components.append(TextArea( text=f"""Final Word: \"{self.actual_final_word}\"""", screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING, + height_ignores_below_baseline=True, # Keep the next line (bits display) snugged up, regardless of text rendering below the baseline )) # Once again show the bits that came from the user's entropy... @@ -359,10 +375,60 @@ class ToolsCalcFinalWordDoneScreen(ButtonListScreen): )) self.components.append(IconTextLine( - icon_name=SeedSignerCustomIconConstants.FINGERPRINT, - icon_color="blue", + icon_name=SeedSignerIconConstants.FINGERPRINT, + icon_color=GUIConstants.INFO_COLOR, label_text="fingerprint", value_text=self.fingerprint, is_text_centered=True, screen_y=self.components[-1].screen_y + self.components[-1].height + 3*GUIConstants.COMPONENT_PADDING, )) + + + +@dataclass +class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen): + fingerprint: str = None + wallet_descriptor_display_name: Any = None + script_type: str = None + custom_derivation_path: str = None + + def __post_init__(self): + self.title = "Address Explorer" + self.is_bottom_list = True + super().__post_init__() + + if self.fingerprint: + self.components.append(IconTextLine( + icon_name=SeedSignerIconConstants.FINGERPRINT, + icon_color=GUIConstants.INFO_COLOR, + label_text="Fingerprint", + value_text=self.fingerprint, + screen_x=GUIConstants.EDGE_PADDING, + screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, + )) + + if self.script_type != SettingsConstants.CUSTOM_DERIVATION: + self.components.append(IconTextLine( + icon_name=SeedSignerIconConstants.DERIVATION, + label_text="Derivation", + value_text=SettingsDefinition.get_settings_entry(attr_name=SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(value=self.script_type), + screen_x=GUIConstants.EDGE_PADDING, + screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING, + )) + else: + self.components.append(IconTextLine( + icon_name=SeedSignerIconConstants.DERIVATION, + label_text="Derivation", + value_text=self.custom_derivation_path, + screen_x=GUIConstants.EDGE_PADDING, + screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING, + )) + + else: + self.components.append(IconTextLine( + label_text="Wallet descriptor", + value_text=self.wallet_descriptor_display_name, + is_text_centered=True, + screen_x=GUIConstants.EDGE_PADDING, + screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, + )) diff --git a/src/seedsigner/gui/toast.py b/src/seedsigner/gui/toast.py new file mode 100644 index 00000000..d21ff02c --- /dev/null +++ b/src/seedsigner/gui/toast.py @@ -0,0 +1,232 @@ +import logging +import time +from dataclasses import dataclass +from seedsigner.gui.components import BaseComponent, GUIConstants, Icon, SeedSignerIconConstants, TextArea +from seedsigner.models.threads import BaseThread + +logger = logging.getLogger(__name__) + + +@dataclass +class ToastOverlay(BaseComponent): + icon_name: str = None + color: str = GUIConstants.NOTIFICATION_COLOR + label_text: str = None + height: int = GUIConstants.ICON_TOAST_FONT_SIZE + 2*GUIConstants.EDGE_PADDING + font_size: int = 19 + outline_thickness: int = 2 # pixels + + def __post_init__(self): + super().__post_init__() + + self.icon = Icon( + image_draw=self.image_draw, + canvas=self.canvas, + screen_x=self.outline_thickness + 2*GUIConstants.EDGE_PADDING, # Push the icon further from the left edge than strictly necessary + icon_name=self.icon_name, + icon_size=GUIConstants.ICON_TOAST_FONT_SIZE, + icon_color=self.color + ) + self.icon.screen_y = self.canvas_height - self.height + int((self.height - self.icon.height)/2) + + self.label = TextArea( + image_draw=self.image_draw, + canvas=self.canvas, + text=self.label_text, + font_size=self.font_size, + font_color=self.color, + edge_padding=0, + is_text_centered=False, + auto_line_break=True, + width=self.canvas_width - self.icon.screen_x - self.icon.width - GUIConstants.COMPONENT_PADDING - self.outline_thickness, + screen_x=self.icon.screen_x + self.icon.width + GUIConstants.COMPONENT_PADDING, + allow_text_overflow=False, + ) + + # Vertically center the message within the toast (for single- or multi-line + # messages). + self.label.screen_y = self.canvas_height - self.height + self.outline_thickness + int((self.height - 2*self.outline_thickness - self.label.height)/2) + + + def render(self): + # Render the toast's solid background + self.image_draw.rounded_rectangle( + (0, self.canvas_height - self.height, self.canvas_width, self.canvas_height), + fill=GUIConstants.BACKGROUND_COLOR, + radius=8, + outline=self.color, + width=self.outline_thickness, + ) + + # Draw the toast visual elements + self.icon.render() + self.label.render() + + self.renderer.show_image() + + + +class BaseToastOverlayManagerThread(BaseThread): + """ + The toast notification popup consists of a gui component (`ToastOverlay`) and this + manager thread that the Controller will use to coordinate handing off resources + between competing toasts, the screensaver, and the current underlying Screen. + + Controller should set BaseThread.keep_running = False to terminate the toast when it + needs to be removed or replaced. + + Controller should set toggle_renderer_lock = True to make the toast temporarily + release the Renderer.lock so another process (e.g. screensaver) can use it. The toast + thread will immediately try to reacquire the lock, but will have to block and wait + until it's available again. Note that this thread will be unresponsive while it + waits to reacquire the lock! + + Note: any process can call lock.release() but it simplifies the logic to try to keep + each process aware of whether it is currently holding the lock or not (i.e. it's + better for the "owner" thread to release the lock itself). + """ + def __init__(self, + activation_delay: int = 0, # seconds before toast is displayed + duration: int = 3, # seconds toast is displayed + ): + from seedsigner.controller import Controller + from seedsigner.gui.renderer import Renderer + from seedsigner.hardware.buttons import HardwareButtons + super().__init__() + self.activation_delay: int = activation_delay + self.duration: int = duration + self._toggle_renderer_lock: bool = False + + self.renderer = Renderer.get_instance() + self.controller = Controller.get_instance() + self.hw_inputs = HardwareButtons.get_instance() + + # Special case when screensaver is running + self.hw_inputs.override_ind = True + + self.toast = self.instantiate_toast() + + + def instantiate_toast(self) -> ToastOverlay: + raise Exception("Must be implemented by subclass") + + + def should_keep_running(self) -> bool: + """ Placeholder for custom exit conditions """ + return True + + + def toggle_renderer_lock(self): + self._toggle_renderer_lock = True + + + def run(self): + logger.info(f"{self.__class__.__name__}: started") + start = time.time() + while time.time() - start < self.activation_delay: + if self.hw_inputs.has_any_input(): + # User has pressed a button, cancel the toast + logger.info(f"{self.__class__.__name__}: Canceling toast due to user input") + return + time.sleep(0.1) + + try: + # Hold onto the Renderer lock so we're guaranteed to restore the original + # screen before any other listener can get a screen write in. + logger.info(f"{self.__class__.__name__}: Acquiring lock") + self.renderer.lock.acquire() + logger.info(f"{self.__class__.__name__}: Lock acquired") + + has_rendered = False + previous_screen_state = None + while self.keep_running and self.should_keep_running(): + if self.hw_inputs.has_any_input(): + # User has pressed a button, hide the toast + logger.info(f"{self.__class__.__name__}: Exiting due to user input") + break + + if self._toggle_renderer_lock: + # Controller has notified us that another process needs the lock + logger.info(f"{self.__class__.__name__}: Releasing lock") + self._toggle_renderer_lock = False + self.renderer.lock.release() + + # pause to avoid race conditions reacquiring the lock + while not self.renderer.lock.locked(): + # Wait for a different process to grab the lock + time.sleep(0.1) + + # Block while waiting to reaquire the lock + logger.info(f"{self.__class__.__name__}: Blocking to re-acquire lock") + self.renderer.lock.acquire() + logger.info(f"{self.__class__.__name__}: Lock re-acquired") + + if not has_rendered: + previous_screen_state = self.renderer.canvas.copy() + logger.info(f"{self.__class__.__name__}: Showing toast") + self.toast.render() + has_rendered = True + + if time.time() - start > self.activation_delay + self.duration and has_rendered: + logger.info(f"{self.__class__.__name__}: Hiding toast") + break + + # Free up cpu resources for main thread + time.sleep(0.1) + + finally: + logger.info(f"{self.__class__.__name__}: exiting") + if has_rendered and self.renderer.lock.locked(): + # As far as we know, we currently hold the Renderer.lock + self.renderer.show_image(previous_screen_state) + logger.info(f"{self.__class__.__name__}: restored previous screen state") + + # We're done, release the lock + self.renderer.lock.release() + + + +class RemoveSDCardToastManagerThread(BaseToastOverlayManagerThread): + def __init__(self, activation_delay=3): + # Note: activation_delay is configurable so the screenshot generator can get the + # toast to immediately render. + super().__init__( + activation_delay=activation_delay, # seconds + duration=1e6, # seconds ("forever") + ) + + + def instantiate_toast(self) -> ToastOverlay: + return ToastOverlay( + icon_name=SeedSignerIconConstants.MICROSD, + label_text="You can remove\nthe SD card now", + font_size=GUIConstants.BODY_FONT_SIZE, + height=GUIConstants.BODY_FONT_SIZE * 2 + GUIConstants.BODY_LINE_SPACING + GUIConstants.EDGE_PADDING, + ) + + + def should_keep_running(self) -> bool: + """ Custom exit condition: keep running until the SD card is removed """ + from seedsigner.hardware.microsd import MicroSD + return MicroSD.get_instance().is_inserted + + + +class SDCardStateChangeToastManagerThread(BaseToastOverlayManagerThread): + def __init__(self, action: str, *args, **kwargs): + # Note: we could just directly detect the MicroSD status here, but passing it in + # via `action` lets us simulate the state we want in the screenshot generator. + from seedsigner.hardware.microsd import MicroSD + if action not in [MicroSD.ACTION__INSERTED, MicroSD.ACTION__REMOVED]: + raise Exception(f"Invalid MicroSD action: {action}") + self.message = "SD card removed" if action == MicroSD.ACTION__REMOVED else "SD card inserted" + + super().__init__(*args, **kwargs) + + + def instantiate_toast(self) -> ToastOverlay: + logger.info("instantiating toast!") + return ToastOverlay( + icon_name=SeedSignerIconConstants.MICROSD, + label_text=self.message, + ) diff --git a/src/seedsigner/hardware/ST7789.py b/src/seedsigner/hardware/ST7789.py index 09cebd20..ebd4952b 100644 --- a/src/seedsigner/hardware/ST7789.py +++ b/src/seedsigner/hardware/ST7789.py @@ -1,7 +1,7 @@ import spidev import RPi.GPIO as GPIO import time -import numpy as np +import array @@ -13,11 +13,11 @@ class ST7789(object): self.height = 240 #Initialize DC RST pin - self._dc = 25 - self._rst = 27 - self._bl = 24 + self._dc = 22 + self._rst = 13 + self._bl = 18 - GPIO.setmode(GPIO.BCM) + GPIO.setmode(GPIO.BOARD) GPIO.setwarnings(False) GPIO.setup(self._dc,GPIO.OUT) GPIO.setup(self._rst,GPIO.OUT) @@ -153,20 +153,17 @@ class ST7789(object): if imwidth != self.width or imheight != self.height: raise ValueError('Image must be same dimensions as display \ ({0}x{1}).' .format(self.width, self.height)) - img = np.asarray(Image) - pix = np.zeros((self.width,self.height,2), dtype = np.uint8) - pix[...,[0]] = np.add(np.bitwise_and(img[...,[0]],0xF8),np.right_shift(img[...,[1]],5)) - pix[...,[1]] = np.add(np.bitwise_and(np.left_shift(img[...,[1]],3),0xE0),np.right_shift(img[...,[2]],3)) - pix = pix.flatten().tolist() + # convert 24-bit RGB-8:8:8 to gBRG-3:5:5:3; then per-pixel byteswap to 16-bit RGB-5:6:5 + arr = array.array("H", Image.convert("BGR;16").tobytes()) + arr.byteswap() + pix = arr.tobytes() self.SetWindows ( 0, 0, self.width, self.height) GPIO.output(self._dc,GPIO.HIGH) - for i in range(0,len(pix),4096): - self._spi.writebytes(pix[i:i+4096]) + self._spi.writebytes2(pix) def clear(self): """Clear contents of image buffer""" _buffer = [0xff]*(self.width * self.height * 2) self.SetWindows ( 0, 0, self.width, self.height) GPIO.output(self._dc,GPIO.HIGH) - for i in range(0,len(_buffer),4096): - self._spi.writebytes(_buffer[i:i+4096]) + self._spi.writebytes2(_buffer) diff --git a/src/seedsigner/hardware/buttons.py b/src/seedsigner/hardware/buttons.py index d1ae20e4..6fecc10f 100644 --- a/src/seedsigner/hardware/buttons.py +++ b/src/seedsigner/hardware/buttons.py @@ -1,20 +1,37 @@ +import logging +from typing import List import RPi.GPIO as GPIO import time from seedsigner.models.singleton import Singleton +logger = logging.getLogger(__name__) + class HardwareButtons(Singleton): - KEY_UP_PIN = 6 - KEY_DOWN_PIN = 19 - KEY_LEFT_PIN = 5 - KEY_RIGHT_PIN = 26 - KEY_PRESS_PIN = 13 + if GPIO.RPI_INFO['P1_REVISION'] == 3: #This indicates that we have revision 3 GPIO + logger.info("Detected 40pin GPIO (Rasbperry Pi 2 and above)") + KEY_UP_PIN = 31 + KEY_DOWN_PIN = 35 + KEY_LEFT_PIN = 29 + KEY_RIGHT_PIN = 37 + KEY_PRESS_PIN = 33 - KEY1_PIN = 21 - KEY2_PIN = 20 - KEY3_PIN = 16 + KEY1_PIN = 40 + KEY2_PIN = 38 + KEY3_PIN = 36 + else: + logger.info("Assuming 26 Pin GPIO (Raspberry P1 1)") + KEY_UP_PIN = 5 + KEY_DOWN_PIN = 11 + KEY_LEFT_PIN = 3 + KEY_RIGHT_PIN = 15 + KEY_PRESS_PIN = 7 + + KEY1_PIN = 16 + KEY2_PIN = 12 + KEY3_PIN = 8 @classmethod def get_instance(cls): @@ -23,7 +40,7 @@ class HardwareButtons(Singleton): cls._instance = cls.__new__(cls) #init GPIO - GPIO.setmode(GPIO.BCM) + GPIO.setmode(GPIO.BOARD) GPIO.setup(HardwareButtons.KEY_UP_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up GPIO.setup(HardwareButtons.KEY_DOWN_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up GPIO.setup(HardwareButtons.KEY_LEFT_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up @@ -59,7 +76,7 @@ class HardwareButtons(Singleton): while True: cur_time = int(time.time() * 1000) - if cur_time - self.last_input_time > controller.screensaver_activation_ms and not controller.screensaver.is_running: + if cur_time - self.last_input_time > controller.screensaver_activation_ms and not controller.is_screensaver_running: # Start the screensaver. Will block execution until input detected. controller.start_screensaver() @@ -145,10 +162,13 @@ class HardwareButtons(Singleton): HardwareButtonsConstants.release_lock = True return True - def check_for_low(self, key) -> bool: - if self.GPIO.input(key) == self.GPIO.LOW: - self.update_last_input_time() - return True + def check_for_low(self, key: int = None, keys: List[int] = None) -> bool: + if key: + keys = [key] + for key in keys: + if self.GPIO.input(key) == self.GPIO.LOW: + self.update_last_input_time() + return True else: return False @@ -158,21 +178,31 @@ class HardwareButtons(Singleton): return True return False - - - # class used as short hand for static button/channel lookup values # TODO: Implement `release_lock` functionality as a global somewhere. Mixes up design # patterns to have a static constants class plus a settable global value. class HardwareButtonsConstants: - KEY_UP = 6 - KEY_DOWN = 19 - KEY_LEFT = 5 - KEY_RIGHT = 26 - KEY_PRESS = 13 - KEY1 = 21 - KEY2 = 20 - KEY3 = 16 + if GPIO.RPI_INFO['P1_REVISION'] == 3: #This indicates that we have revision 3 GPIO + KEY_UP = 31 + KEY_DOWN = 35 + KEY_LEFT = 29 + KEY_RIGHT = 37 + KEY_PRESS = 33 + + KEY1 = 40 + KEY2 = 38 + KEY3 = 36 + else: + KEY_UP = 5 + KEY_DOWN = 11 + KEY_LEFT = 3 + KEY_RIGHT = 15 + KEY_PRESS = 7 + + KEY1 = 16 + KEY2 = 12 + KEY3 = 8 + OVERRIDE = 1000 ALL_KEYS = [ diff --git a/src/seedsigner/hardware/camera.py b/src/seedsigner/hardware/camera.py index c458339f..59ff4101 100644 --- a/src/seedsigner/hardware/camera.py +++ b/src/seedsigner/hardware/camera.py @@ -1,11 +1,10 @@ import io -import numpy from picamera import PiCamera from PIL import Image -from seedsigner.models import Singleton from seedsigner.hardware.pivideostream import PiVideoStream -from seedsigner.models.settings import SettingsConstants +from seedsigner.models.settings import Settings, SettingsConstants +from seedsigner.models.singleton import Singleton @@ -17,7 +16,6 @@ class Camera(Singleton): @classmethod def get_instance(cls): # This is the only way to access the one and only Controller - from seedsigner.models import Settings if cls._instance is None: cls._instance = cls.__new__(cls) cls._instance._camera_rotation = int(Settings.get_instance().get_value(SettingsConstants.SETTING__CAMERA_ROTATION)) @@ -40,7 +38,7 @@ class Camera(Singleton): return frame else: if frame is not None: - return Image.fromarray(frame.astype('uint8'), 'RGB').rotate(90 + self._camera_rotation) + return Image.fromarray(frame.astype('uint8'), 'RGB').convert('RGBA').rotate(90 + self._camera_rotation) return None diff --git a/src/seedsigner/hardware/microsd.py b/src/seedsigner/hardware/microsd.py new file mode 100644 index 00000000..da545b7f --- /dev/null +++ b/src/seedsigner/hardware/microsd.py @@ -0,0 +1,73 @@ +import logging +import os +import time + +from seedsigner.models.singleton import Singleton +from seedsigner.models.threads import BaseThread +from seedsigner.models.settings import Settings + +logger = logging.getLogger(__name__) + + +class MicroSD(Singleton, BaseThread): + MOUNT_POINT = "/mnt/microsd" + FIFO_PATH = "/tmp/mdev_fifo" + FIFO_MODE = 0o600 + ACTION__INSERTED = "add" + ACTION__REMOVED = "remove" + + + @classmethod + def get_instance(cls): + # This is the only way to access the one and only instance + if cls._instance is None: + # Instantiate the one and only instance + microsd = cls.__new__(cls) + cls._instance = microsd + + # explicitly call BaseThread __init__ since multiple class inheritance + BaseThread.__init__(microsd) + + return cls._instance + + + @property + def is_inserted(self): + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + return os.path.exists(MicroSD.MOUNT_POINT) + else: + # Always True for Raspi OS + return True + + + def start_detection(self): + self.start() + + + def run(self): + from seedsigner.controller import Controller + from seedsigner.gui.toast import SDCardStateChangeToastManagerThread + action = "" + + # explicitly only microsd add/remove detection in seedsigner-os + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + + # at start-up, get current status and inform Settings + Settings.handle_microsd_state_change( + action=MicroSD.ACTION__INSERTED if self.is_inserted else MicroSD.ACTION__REMOVED + ) + + if os.path.exists(self.FIFO_PATH): + os.remove(self.FIFO_PATH) + + os.mkfifo(self.FIFO_PATH, self.FIFO_MODE) + + while self.keep_running: + with open(self.FIFO_PATH) as fifo: + action = fifo.read() + logger.info(f"fifo message: {action}") + + Settings.handle_microsd_state_change(action=action) + Controller.get_instance().activate_toast(SDCardStateChangeToastManagerThread(action=action)) + + time.sleep(0.1) diff --git a/src/seedsigner/hardware/pivideostream.py b/src/seedsigner/hardware/pivideostream.py index cb3031b3..06a1ebb7 100644 --- a/src/seedsigner/hardware/pivideostream.py +++ b/src/seedsigner/hardware/pivideostream.py @@ -1,9 +1,12 @@ # import the necessary packages +import logging from picamera.array import PiRGBArray from picamera import PiCamera from threading import Thread import time +logger = logging.getLogger(__name__) + # Modified from: https://github.com/jrosebr1/imutils class PiVideoStream: @@ -41,7 +44,7 @@ class PiVideoStream: # if the thread indicator variable is set, stop the thread # and resource camera resources if self.should_stop: - print("PiVideoStream: closing everything") + logger.info("PiVideoStream: closing everything") self.stream.close() self.rawCapture.close() self.camera.close() diff --git a/src/seedsigner/helpers/embit_utils.py b/src/seedsigner/helpers/embit_utils.py new file mode 100644 index 00000000..7e260aa4 --- /dev/null +++ b/src/seedsigner/helpers/embit_utils.py @@ -0,0 +1,202 @@ +import embit + +from binascii import b2a_base64 +from hashlib import sha256 + +from embit import bip32, compact, ec +from embit.bip32 import HDKey +from embit.descriptor import Descriptor +from embit.networks import NETWORKS +from embit.util import secp256k1 + + +from seedsigner.models.settings_definition import SettingsConstants + + +""" + Collection of generic embit-powered util methods. +""" +# TODO: PR these directly into `embit`? Or replace with new/existing methods already in `embit`? + + +# TODO: Refactor `wallet_type` to conform to our `sig_type` naming convention +def get_standard_derivation_path(network: str = SettingsConstants.MAINNET, wallet_type: str = SettingsConstants.SINGLE_SIG, script_type: str = SettingsConstants.NATIVE_SEGWIT) -> str: + if network == SettingsConstants.MAINNET: + network_path = "0'" + elif network == SettingsConstants.TESTNET: + network_path = "1'" + elif network == SettingsConstants.REGTEST: + network_path = "1'" + else: + raise Exception("Unexpected network") + + if wallet_type == SettingsConstants.SINGLE_SIG: + if script_type == SettingsConstants.LEGACY_P2PKH: + return f"m/44'/{network_path}/0'" + elif script_type == SettingsConstants.NESTED_SEGWIT: + return f"m/49'/{network_path}/0'" + elif script_type == SettingsConstants.NATIVE_SEGWIT: + return f"m/84'/{network_path}/0'" + elif script_type == SettingsConstants.TAPROOT: + return f"m/86'/{network_path}/0'" + else: + raise Exception("Unexpected script type") + + elif wallet_type == SettingsConstants.MULTISIG: + if script_type == SettingsConstants.LEGACY_P2PKH: + return f"m/45'" #BIP45 + elif script_type == SettingsConstants.NESTED_SEGWIT: + return f"m/48'/{network_path}/0'/1'" + elif script_type == SettingsConstants.NATIVE_SEGWIT: + return f"m/48'/{network_path}/0'/2'" + elif script_type == SettingsConstants.TAPROOT: + raise Exception("Taproot multisig/musig not yet supported") + else: + raise Exception("Unexpected script type") + else: + raise Exception("Unexpected wallet type") # checks that all inputs are from the same wallet + + + +def get_xpub(seed_bytes, derivation_path: str, embit_network: str = "main") -> HDKey: + root = bip32.HDKey.from_seed(seed_bytes, version=NETWORKS[embit_network]["xprv"]) + xprv = root.derive(derivation_path) + xpub = xprv.to_public() + return xpub + + + +def get_single_sig_address(xpub: HDKey, script_type: str = SettingsConstants.NATIVE_SEGWIT, index: int = 0, is_change: bool = False, embit_network: str = "main") -> str: + if is_change: + pubkey = xpub.derive([1,index]).key + else: + pubkey = xpub.derive([0,index]).key + + if script_type == SettingsConstants.LEGACY_P2PKH: + return embit.script.p2pkh(pubkey).address(network=NETWORKS[embit_network]) + + elif script_type == SettingsConstants.NESTED_SEGWIT: + return embit.script.p2sh(embit.script.p2wpkh(pubkey)).address(network=NETWORKS[embit_network]) + + elif script_type == SettingsConstants.NATIVE_SEGWIT: + return embit.script.p2wpkh(pubkey).address(network=NETWORKS[embit_network]) + + elif script_type == SettingsConstants.TAPROOT: + return embit.script.p2tr(pubkey).address(network=NETWORKS[embit_network]) + + + +def get_multisig_address(descriptor: Descriptor, index: int = 0, is_change: bool = False, embit_network: str = "main"): + if is_change: + branch_index = 1 + else: + branch_index = 0 + + # Can derive p2wsh, p2sh-p2wsh, and legacy (non-segwit) p2sh + if descriptor.is_segwit or (descriptor.is_legacy and descriptor.is_basic_multisig): + return descriptor.derive(index, branch_index=branch_index).script_pubkey().address(network=NETWORKS[embit_network]) + + elif descriptor.is_taproot: + # TODO: Not yet implemented! + raise Exception("Taproot verification not yet implemented!") + + raise Exception(f"{descriptor.script_pubkey().script_type()} address verification not yet implemented!") + + + +def get_embit_network_name(settings_name): + """ Convert SeedSigner SettingsConstants for `network` to embit's NETWORK key """ + lookup = { + SettingsConstants.MAINNET: "main", + SettingsConstants.TESTNET: "test", + SettingsConstants.REGTEST: "regtest", + } + return lookup.get(settings_name) + + + +def parse_derivation_path(derivation_path: str) -> dict: + """ + Parses a derivation path into its related SettingsConstants equivalents. + + Primarily only supports single sig derivation paths. + + May return None for fields it cannot parse. + """ + # Support either m/44'/... or m/44h/... style + derivation_path = derivation_path.replace("'", "h") + + sections = derivation_path.split("/") + + if sections[1] == "48h": + # So far this helper is only meant for single sig message signing + raise Exception("Not implemented") + + lookups = { + "script_types": { + "44h": SettingsConstants.LEGACY_P2PKH, + "49h": SettingsConstants.NESTED_SEGWIT, + "84h": SettingsConstants.NATIVE_SEGWIT, + "86h": SettingsConstants.TAPROOT, + }, + "networks": { + "0h": SettingsConstants.MAINNET, + "1h": [SettingsConstants.TESTNET, SettingsConstants.REGTEST], + } + } + + details = dict() + details["script_type"] = lookups["script_types"].get(sections[1]) + if not details["script_type"]: + details["script_type"] = SettingsConstants.CUSTOM_DERIVATION + details["network"] = lookups["networks"].get(sections[2]) + + # Check if there's a standard change path + if sections[-2] in ["0", "1"]: + details["is_change"] = sections[-2] == "1" + else: + details["is_change"] = None + + # Check if there's a standard address index + if sections[-1].isdigit(): + details["index"] = int(sections[-1]) + else: + details["index"] = None + + if details["is_change"] is not None and details["index"] is not None: + # standard change and addr index; safe to truncate to the wallet level + details["wallet_derivation_path"] = "/".join(sections[:-2]) + else: + details["wallet_derivation_path"] = None + + details["clean_match"] = True + for k, v in details.items(): + if v is None: + # At least one field couldn't be parsed + details["clean_match"] = False + break + + return details + + + +def sign_message(seed_bytes: bytes, derivation: str, msg: bytes, compressed: bool = True, embit_network: str = "main") -> bytes: + """ + from: https://github.com/cryptoadvance/specter-diy/blob/b58a819ef09b2bca880a82c7e122618944355118/src/apps/signmessage/signmessage.py + """ + """Sign message with private key""" + msghash = sha256( + sha256( + b"\x18Bitcoin Signed Message:\n" + compact.to_bytes(len(msg)) + msg + ).digest() + ).digest() + + root = bip32.HDKey.from_seed(seed_bytes, version=NETWORKS[embit_network]["xprv"]) + prv = root.derive(derivation).key + sig = secp256k1.ecdsa_sign_recoverable(msghash, prv._secret) + flag = sig[64] + sig = ec.Signature(sig[:64]) + c = 4 if compressed else 0 + flag = bytes([27 + flag + c]) + ser = flag + secp256k1.ecdsa_signature_serialize_compact(sig._sig) + return b2a_base64(ser).strip().decode() diff --git a/src/seedsigner/helpers/mnemonic_generation.py b/src/seedsigner/helpers/mnemonic_generation.py index dab63a48..b3e90680 100644 --- a/src/seedsigner/helpers/mnemonic_generation.py +++ b/src/seedsigner/helpers/mnemonic_generation.py @@ -2,21 +2,40 @@ import hashlib import unicodedata from embit import bip39 -from embit.bip39 import mnemonic_to_bytes, mnemonic_from_bytes -from typing import List - +from seedsigner.models.settings_definition import SettingsConstants from seedsigner.models.seed import Seed +""" + This is SeedSigner's internal mnemonic generation utility. + + It can also be run as an independently-executable CLI to facilitate external + verification of SeedSigner's results for a given input entropy. -def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str]: + see: docs/dice_verification.md (the "Command Line Tool" section). +""" + +DICE__NUM_ROLLS__12WORD = 50 +DICE__NUM_ROLLS__24WORD = 99 + + + +def calculate_checksum(mnemonic: list | str, wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> list[str]: """ Provide 12- or 24-word mnemonic, returns complete mnemonic w/checksum as a list. + Mnemonic may be a list of words or a string of words separated by spaces or commas. + If 11- or 23-words are provided, append word `0000` to end of list as temp final word. """ + if type(mnemonic) == str: + import re + # split on commas or spaces + mnemonic = re.findall(r'[^,\s]+', mnemonic) + if len(mnemonic) in [11, 23]: - mnemonic.append(Seed.get_wordlist(wordlist_language_code)[0]) + temp_final_word = Seed.get_wordlist(wordlist_language_code)[0] + mnemonic.append(temp_final_word) if len(mnemonic) not in [12, 24]: raise Exception("Pass in a 12- or 24-word mnemonic") @@ -37,28 +56,68 @@ def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str] -def generate_mnemonic_from_bytes(entropy_bytes) -> List[str]: - return bip39.mnemonic_from_bytes(entropy_bytes).split() +def generate_mnemonic_from_bytes(entropy_bytes, wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> list[str]: + return bip39.mnemonic_from_bytes(entropy_bytes, wordlist=Seed.get_wordlist(wordlist_language_code)).split() -def generate_mnemonic_from_dice(roll_data: str) -> List[str]: +def generate_mnemonic_from_dice(roll_data: str, wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> list[str]: + """ + Takes a string of 50 or 99 dice rolls and returns a 12- or 24-word mnemonic. + + Uses the iancoleman.io/bip39 and bitcoiner.guide/seed "Base 10" or "Hex" mode approach: + * dice rolls are treated as string data. + * hashed via SHA256. + + Important note: This method is NOT compatible with iancoleman's "Dice" mode. + """ entropy_bytes = hashlib.sha256(roll_data.encode()).digest() - if len(roll_data) == 50: + if len(roll_data) == DICE__NUM_ROLLS__12WORD: # 12-word mnemonic; only use 128bits / 16 bytes entropy_bytes = entropy_bytes[:16] # Return as a list - return bip39.mnemonic_from_bytes(entropy_bytes).split() + return bip39.mnemonic_from_bytes(entropy_bytes, wordlist=Seed.get_wordlist(wordlist_language_code)).split() + + + +def generate_mnemonic_from_coin_flips(coin_flips: str, wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> list[str]: + """ + Takes a string of 128 or 256 0s and 1s and returns a 12- or 24-word mnemonic. + + Uses the iancoleman.io/bip39 and bitcoiner.guide/seed "Binary" mode approach: + * binary digit stream is treated as string data. + * hashed via SHA256. + """ + entropy_bytes = hashlib.sha256(coin_flips.encode()).digest() + + if len(coin_flips) == 128: + # 12-word mnemonic; only use 128bits / 16 bytes + entropy_bytes = entropy_bytes[:16] + + # Return as a list + return bip39.mnemonic_from_bytes(entropy_bytes, wordlist=Seed.get_wordlist(wordlist_language_code)).split() + + + +def get_partial_final_word(coin_flips: str, wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> str: + """ Look up the partial final word for the given coin flips. + 7 coin flips: 0101010 + **** where the final 4 bits will be replaced with the checksum + 3 coin flips: 010 + ******** where the final 8 bits will be replaced with the checksum + """ + binary_string = coin_flips + "0" * (11 - len(coin_flips)) + wordlist_index = int(binary_string, 2) + + return Seed.get_wordlist(wordlist_language_code)[wordlist_index] # Note: This currently isn't being used since we're now chaining hashed bytes for the # image-based entropy and aren't just ingesting a single image. -def generate_mnemonic_from_image(image) -> List[str]: +def generate_mnemonic_from_image(image, wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> list[str]: import hashlib hash = hashlib.sha256(image.tobytes()) # Return as a list - return bip39.mnemonic_from_bytes(hash.digest()).split() + return bip39.mnemonic_from_bytes(hash.digest(), wordlist=Seed.get_wordlist(wordlist_language_code)).split() diff --git a/src/seedsigner/helpers/qr.py b/src/seedsigner/helpers/qr.py index 27f10ac6..5377a1a7 100644 --- a/src/seedsigner/helpers/qr.py +++ b/src/seedsigner/helpers/qr.py @@ -1,7 +1,7 @@ import qrcode from qrcode.image.styledpil import StyledPilImage from qrcode.image.styles.moduledrawers import CircleModuleDrawer, GappedSquareModuleDrawer -from PIL import Image +from PIL import Image, ImageDraw import subprocess class QR: @@ -13,19 +13,73 @@ class QR: return def qrimage(self, data, width=240, height=240, border=3, style=None, background_color="#444"): - qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=5, border=border ) + box_size = 5 + qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=box_size, border=border ) qr.add_data(data) qr.make(fit=True) if not style or style == QR.STYLE__DEFAULT: return qr.make_image(fill_color="black", back_color=background_color).resize((width,height)).convert('RGBA') else: if style == QR.STYLE__ROUNDED: - return qr.make_image( + qr_image = qr.make_image( fill_color="black", back_color=background_color, image_factory=StyledPilImage, module_drawer=CircleModuleDrawer() - ).resize((width,height)).convert('RGBA') + ) + + qr_image_width, _ = qr_image.size + qr_code_dims = int(qr_image_width / box_size) - 2*border + + if qr_code_dims > 21: + # The ROUNDED style mis-renders the small lower-right registration box in 25x25 + # and 29x29. + draw = ImageDraw.Draw(qr_image) + if qr_code_dims == 25: + # registration block starts at 16, 16 and is 5x5 + starting_point = 16 + border + + elif qr_code_dims == 29: + # The registration block starts at 20,20 and is 5x5 + starting_point = 20 + border + + else: + raise Exception(f"Unrecognized qrimage size: {qr_code_dims}") + + # Render black rectangular lines on top of the qr_image to square off + # the registration block. + lines = [ + ( + # top + (starting_point*box_size, starting_point*box_size), + (starting_point*box_size + 5*box_size - 1, starting_point*box_size + box_size - 1) + ), + ( + # right + (starting_point*box_size + 4*box_size, starting_point*box_size), + (starting_point*box_size + 5*box_size - 1, starting_point*box_size + 5*box_size - 1) + ), + ( + # left + (starting_point*box_size, starting_point*box_size), + (starting_point*box_size + box_size - 1, starting_point*box_size + 5*box_size - 1) + ), + ( + # bottom + (starting_point*box_size + box_size, starting_point*box_size + 4*box_size), + (starting_point*box_size + 5*box_size - 1, starting_point*box_size + 5*box_size - 1) + ), + ( + # center dot + (starting_point*box_size + 2*box_size, starting_point*box_size + 2*box_size), + (starting_point*box_size + 3*box_size - 1, starting_point*box_size + 3*box_size - 1) + ) + ] + + for line in lines: + draw.rectangle(line, fill="black") + + return qr_image.resize((width,height)).convert('RGBA') elif style == QR.STYLE__GRID: return qr.make_image( @@ -42,12 +96,12 @@ class QR: else: border_str = "3" - cmd = f"""qrencode -m {border_str} -s 3 -l L --foreground=000000 --background={background_color} -t PNG -o "/dev/shm/qrcode.png" "{str(data)}" """ + cmd = f"""qrencode -m {border_str} -s 3 -l L --foreground=000000 --background={background_color} -t PNG -o "/tmp/qrcode.png" "{str(data)}" """ rv = subprocess.call(cmd, shell=True) # if qrencode fails, fall back to only encoder if rv != 0: return self.qrimage(data,width,height,border) - img = Image.open("/dev/shm/qrcode.png").resize((width,height), Image.NEAREST).convert("RGBA") + img = Image.open("/tmp/qrcode.png").resize((width,height), Image.NEAREST).convert("RGBA") return img diff --git a/src/seedsigner/helpers/ur2/bytewords.py b/src/seedsigner/helpers/ur2/bytewords.py index b7e8432a..a1204ed2 100644 --- a/src/seedsigner/helpers/ur2/bytewords.py +++ b/src/seedsigner/helpers/ur2/bytewords.py @@ -126,7 +126,7 @@ class Bytewords: elif style == Bytewords_Style_minimal: return encode_minimal(bytes) else: - assert(False) + assert False @staticmethod def decode(style, str): @@ -137,4 +137,4 @@ class Bytewords: elif style == Bytewords_Style_minimal: return decode(str, 0, 2) else: - assert(False) + assert False diff --git a/src/seedsigner/helpers/ur2/fountain_decoder.py b/src/seedsigner/helpers/ur2/fountain_decoder.py index c0b3f873..9e8dcdf3 100644 --- a/src/seedsigner/helpers/ur2/fountain_decoder.py +++ b/src/seedsigner/helpers/ur2/fountain_decoder.py @@ -4,7 +4,7 @@ # Copyright © 2020 Foundation Devices, Inc. # Licensed under the "BSD-2-Clause Plus Patent License" # - +import time from .fountain_utils import choose_fragments, contains, is_strict_subset, set_difference from .utils import join_lists, join_bytes, crc32_int, xor_with, take_first @@ -71,13 +71,53 @@ class FountainDecoder: def result_error(self): return self.result - def estimated_percent_complete(self): + + def estimated_percent_complete(self, weight_mixed_frames: bool = False): + """ + Weighted mixed frame method: + * counts completed frames + * counts each additional frame that is currently XORed in a mixed frame; its + score is weighted by the number of frames mixed together (1/num frames mixed). + """ if self.is_complete(): return 1 if self.expected_part_indexes == None: return 0 - estimated_input_parts = self.expected_part_count() * 1.75 - return min(0.99, self.processed_parts_count / estimated_input_parts) + + if not weight_mixed_frames: + # Original estimation method + estimated_input_parts = self.expected_part_count() * 1.75 + return min(0.99, self.processed_parts_count / estimated_input_parts) + else: + parts = self.expected_part_count() if self.expected_part_indexes != None else 'None' + mixed = [] + mixed_index_scoring = {} + mixed_set = set() + for indexes, p in self.mixed_parts.items(): + if not indexes: + continue + mixed.append(self.indexes_to_string(indexes)) + mixed_set.update(indexes) + score = 1.0 / float(len(indexes)) + for index in indexes: + if index not in mixed_index_scoring: + mixed_index_scoring[index] = 0.0 + + # sum up partial scores + mixed_index_scoring[index] += score + + mixed_score = 0.0 + for index, score in mixed_index_scoring.items(): + # set a ceiling; don't let an index in a mixed/XOR frame + # achieve equal weight as a fully decoded frame. Also if + # the ceiling is too high, can potentially see your + # reported progress percentage DECREASE during a decode. + mixed_score += min(score, 0.75) + + num_complete = len(self.received_part_indexes) + weighted_estimate = (num_complete + mixed_score) / float(parts) + return weighted_estimate + def receive_part(self, encoder_part): # Don't process the part if we're already done @@ -93,6 +133,9 @@ class FountainDecoder: self.last_part_indexes = p.indexes self.enqueue(p) + num_complete = len(self.received_part_indexes) + num_mixed_frames = len(self.mixed_parts) + # Process the queue until we're done or the queue is empty while not self.is_complete() and len(self.queued_parts) != 0: self.process_queue_item() @@ -101,6 +144,12 @@ class FountainDecoder: self.processed_parts_count += 1 # self.print_part_end() + # self.print_state() + + if num_complete == len(self.received_part_indexes) and num_mixed_frames == len(self.mixed_parts): + # This part didn't add any new info + # print("No new data") + return False return True @@ -114,6 +163,7 @@ class FountainDecoder: self.queued_parts.append(p) def process_queue_item(self): + start = time.time() part = self.queued_parts.pop(0) # self.print_part(part) @@ -121,6 +171,8 @@ class FountainDecoder: self.process_simple_part(part) else: self.process_mixed_part(part) + + # print(f"Queue processing: {int((time.time() - start)*1000.0)}ms") # self.print_state() def reduce_mixed_by(self, p): @@ -141,6 +193,7 @@ class FountainDecoder: new_mixed[reduced_part.indexes] = reduced_part self.mixed_parts = new_mixed + # print(self.mixed_parts.keys()) def reduce_part_by_part(self, a, b): # If the fragments mixed into `b` are a strict (proper) subset of those in `a`... @@ -255,7 +308,7 @@ class FountainDecoder: elif self.is_failure(): return 'Exception: {}'.format(self.result) else: - assert(False) + assert False def print_part(self, p): print('part indexes: {}'.format(self.indexes_to_string(p.indexes))) @@ -266,13 +319,23 @@ class FountainDecoder: print("processed: {}, expected: {}, received: {}, percent: {}%".format(self.processed_parts_count, expected, len(self.received_part_indexes), percent)) def print_state(self): - parts = self.expected_part_count() if self.expected_part_indexes != None else 'None' - received = self.indexes_to_string(self.received_part_indexes) + guesstimate = self.estimated_percent_complete(weight_mixed_frames=True) + original_metric = self.estimated_percent_complete() mixed = [] - for indexes, p in self.mixed_parts.items(): - mixed.append(self.indexes_to_string(indexes)) - - mixed_s = "[{}]".format(', '.join(mixed)) - queued = len(self.queued_parts) - res = self.result_description() - print('parts: {}, received: {}, mixed: {}, queued: {}, result: {}'.format(parts, received, mixed_s, queued, res)) + mixed_set = set() + try: + for indexes, p in self.mixed_parts.items(): + if not indexes or len(indexes) == 0: + continue + mixed.append(self.indexes_to_string(indexes)) + mixed_set.update(indexes) + + num_complete = len(self.received_part_indexes) + + mixed_s = "[{}]".format(', '.join(mixed)) + queued = len(self.queued_parts) + print(f"{original_metric*100.0:5.1f}% | {guesstimate*100.0:5.1f}% | done: {num_complete:2d}, mixed: {len(mixed_set):2d}, queued: {queued}, frames: {self.processed_parts_count:2d} | {mixed_s}") + + except Exception as e: + import traceback + traceback.print_exc() diff --git a/src/seedsigner/helpers/ur2/fountain_encoder.py b/src/seedsigner/helpers/ur2/fountain_encoder.py index d539e460..af5dd338 100644 --- a/src/seedsigner/helpers/ur2/fountain_encoder.py +++ b/src/seedsigner/helpers/ur2/fountain_encoder.py @@ -84,18 +84,19 @@ class Part: class FountainEncoder: def __init__(self, message, max_fragment_len, first_seq_num = 0, min_fragment_len = 10): - assert(len(message) <= MAX_UINT32) + assert len(message) <= MAX_UINT32 self.message_len = len(message) self.checksum = crc32_int(message) self.fragment_len = FountainEncoder.find_nominal_fragment_length(self.message_len, min_fragment_len, max_fragment_len) self.fragments = FountainEncoder.partition_message(message, self.fragment_len) self.seq_num = first_seq_num + self.current_part: Part = None @staticmethod def find_nominal_fragment_length(message_len, min_fragment_len, max_fragment_len): - assert(message_len > 0) - assert(min_fragment_len > 0) - assert(max_fragment_len >= min_fragment_len) + assert message_len > 0 + assert min_fragment_len > 0 + assert max_fragment_len >= min_fragment_len max_fragment_count = message_len // min_fragment_len fragment_len = None @@ -104,7 +105,7 @@ class FountainEncoder: if fragment_len <= max_fragment_len: break - assert(fragment_len != None) + assert fragment_len != None return fragment_len @@ -143,7 +144,17 @@ class FountainEncoder: indexes = choose_fragments(self.seq_num, self.seq_len(), self.checksum) mixed = self.mix(indexes) data = bytes(mixed) - return Part(self.seq_num, self.seq_len(), self.message_len, self.checksum, data) + self.current_part = Part(self.seq_num, self.seq_len(), self.message_len, self.checksum, data) + return self.current_part + + + def restart(self): + """ + Restart from the beginning; each cycle's first n frames are full data frames + (not XOR composites). + """ + self.seq_num = 0 + def mix(self, indexes): result = [0] * self.fragment_len diff --git a/src/seedsigner/helpers/ur2/random_sampler.py b/src/seedsigner/helpers/ur2/random_sampler.py index 087789e8..d1b80411 100644 --- a/src/seedsigner/helpers/ur2/random_sampler.py +++ b/src/seedsigner/helpers/ur2/random_sampler.py @@ -9,11 +9,11 @@ class RandomSampler: def __init__(self, probs): for p in probs: - assert(p > 0) + assert p > 0 # Normalize given probabilities total = sum(probs) - assert(total > 0) + assert total > 0 n = len(probs) diff --git a/src/seedsigner/helpers/ur2/ur_decoder.py b/src/seedsigner/helpers/ur2/ur_decoder.py index c0baf21f..bb282b09 100644 --- a/src/seedsigner/helpers/ur2/ur_decoder.py +++ b/src/seedsigner/helpers/ur2/ur_decoder.py @@ -153,8 +153,8 @@ class URDecoder: def processed_parts_count(self): return self.fountain_decoder.processed_parts_count - def estimated_percent_complete(self): - return self.fountain_decoder.estimated_percent_complete() + def estimated_percent_complete(self, weight_mixed_frames: bool = False): + return self.fountain_decoder.estimated_percent_complete(weight_mixed_frames=weight_mixed_frames) def is_success(self): result = self.result diff --git a/src/seedsigner/helpers/ur2/ur_encoder.py b/src/seedsigner/helpers/ur2/ur_encoder.py index ac1cbc7d..082ad17b 100644 --- a/src/seedsigner/helpers/ur2/ur_encoder.py +++ b/src/seedsigner/helpers/ur2/ur_encoder.py @@ -34,12 +34,26 @@ class UREncoder: def is_single_part(self): return self.fountain_encoder.is_single_part() - def next_part(self): - part = self.fountain_encoder.next_part() + def next_part(self) -> str: if self.is_single_part(): return UREncoder.encode(self.ur) else: + part = self.fountain_encoder.next_part() return UREncoder.encode_part(self.ur.type, part) + + def current_part(self) -> str: + if self.is_single_part(): + return UREncoder.encode(self.ur) + else: + part = self.fountain_encoder.current_part + if not part: + part = self.fountain_encoder.next_part() + return UREncoder.encode_part(self.ur.type, part) + + + def restart(self): + self.fountain_encoder.restart() + @staticmethod def encode_part(type, part): diff --git a/src/seedsigner/helpers/ur2/utils.py b/src/seedsigner/helpers/ur2/utils.py index 4e88c363..31a0dfe1 100644 --- a/src/seedsigner/helpers/ur2/utils.py +++ b/src/seedsigner/helpers/ur2/utils.py @@ -57,7 +57,7 @@ def join_bytes(list_of_ba): def xor_into(target, source): count = len(target) - assert(count == len(source)) # Must be the same length + assert count == len(source) # Must be the same length for i in range(count): target[i] ^= source[i] diff --git a/src/seedsigner/models/__init__.py b/src/seedsigner/models/__init__.py index c21cf9cb..e69de29b 100644 --- a/src/seedsigner/models/__init__.py +++ b/src/seedsigner/models/__init__.py @@ -1,10 +0,0 @@ -# Must import crucial base class first! -from .singleton import Singleton, ConfigurableSingleton - -from .seed import * -from .qr_type import * -from .decode_qr import * -from .encode_qr import * -from .psbt_parser import * -from .seed_storage import * -from .settings import * diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index 3928ff8e..4ac16642 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -9,14 +9,13 @@ from embit import psbt, bip39 from pyzbar import pyzbar from pyzbar.pyzbar import ZBarSymbol from urtypes.crypto import PSBT as UR_PSBT -from urtypes.crypto import Account, HDKey, Output, Keypath, PathComponent, SCRIPT_EXPRESSION_TAG_MAP +from urtypes.crypto import Account, Output from urtypes.bytes import Bytes from seedsigner.helpers.ur2.ur_decoder import URDecoder -from seedsigner.models.psbt_parser import PSBTParser - -from . import QRType, Seed -from .settings import SettingsConstants +from seedsigner.models.qr_type import QRType +from seedsigner.models.seed import Seed +from seedsigner.models.settings import SettingsConstants logger = logging.getLogger(__name__) @@ -84,6 +83,9 @@ class DecodeQR: elif self.qr_type == QRType.BITCOIN_ADDRESS: self.decoder = BitcoinAddressQrDecoder() # Single Segment bitcoin address + elif self.qr_type == QRType.SIGN_MESSAGE: + self.decoder = SignMessageQrDecoder() # Single Segment sign message request + elif self.qr_type == QRType.WALLET__SPECTER: self.decoder = SpecterWalletQrDecoder() # Specter Desktop Wallet Export decoder @@ -118,11 +120,14 @@ class DecodeQR: qr_str = data if self.qr_type in [QRType.PSBT__UR2, QRType.OUTPUT__UR, QRType.ACCOUNT__UR, QRType.BYTES__UR]: - self.decoder.receive_part(qr_str) + added_part = self.decoder.receive_part(qr_str) if self.decoder.is_complete(): self.complete = True return DecodeQRStatus.COMPLETE - return DecodeQRStatus.PART_COMPLETE # segment added to ur2 decoder + if added_part: + return DecodeQRStatus.PART_COMPLETE + else: + return DecodeQRStatus.PART_EXISTING else: # All other formats use the same method signature @@ -132,6 +137,9 @@ class DecodeQR: return rt + # TODO: Refactor all of these specific `get_` to just something generic like + # `get_data` and let each QRDecoder class return whatever it needs to as a + # str, tuple, dict, etc? def get_psbt(self): if self.complete: data = self.get_data_psbt() @@ -175,12 +183,7 @@ class DecodeQR: def get_settings_data(self): if self.is_settings: - return self.decoder.settings - - - def get_settings_config_name(self): - if self.is_settings: - return self.decoder.config_name + return self.decoder.data def get_address(self): @@ -193,6 +196,15 @@ class DecodeQR: return self.decoder.get_address_type() + def get_qr_data(self) -> dict: + """ + This provides a single access point for external code to retrieve the QR data, + regardless of which decoder is actually instantiated. + """ + # TODO: Implement this approach across all decoders + return self.decoder.get_qr_data() + + def get_wallet_descriptor(self): if self.is_wallet_descriptor: if self.qr_type in [QRType.OUTPUT__UR, QRType.ACCOUNT__UR, QRType.BYTES__UR]: @@ -210,12 +222,12 @@ class DecodeQR: return self.decoder.get_wallet_descriptor() - def get_percent_complete(self) -> int: + def get_percent_complete(self, weight_mixed_frames: bool = False) -> int: if not self.decoder: return 0 if self.qr_type in [QRType.PSBT__UR2, QRType.OUTPUT__UR, QRType.ACCOUNT__UR, QRType.BYTES__UR]: - return int(self.decoder.estimated_percent_complete() * 100) + return int(self.decoder.estimated_percent_complete(weight_mixed_frames=weight_mixed_frames) * 100) elif self.qr_type in [QRType.PSBT__SPECTER]: if self.decoder.total_segments == None: @@ -252,6 +264,7 @@ class DecodeQR: QRType.PSBT__BASE43, ] + @property def is_seed(self): return self.qr_type in [ @@ -273,6 +286,11 @@ class DecodeQR: return self.qr_type == QRType.BITCOIN_ADDRESS + @property + def is_sign_message(self): + return self.qr_type == QRType.SIGN_MESSAGE + + @property def is_wallet_descriptor(self): check = self.qr_type in [QRType.WALLET__SPECTER, QRType.WALLET__UR, QRType.WALLET__CONFIGFILE, QRType.WALLET__GENERIC, QRType.OUTPUT__UR] @@ -291,7 +309,7 @@ class DecodeQR: @staticmethod - def extract_qr_data(image, is_binary:bool = False) -> str: + def extract_qr_data(image, is_binary:bool = False) -> str | None: if image is None: return None @@ -323,10 +341,10 @@ class DecodeQR: # PSBT if re.search("^UR:CRYPTO-PSBT/", s, re.IGNORECASE): return QRType.PSBT__UR2 - + elif re.search("^UR:CRYPTO-OUTPUT/", s, re.IGNORECASE): return QRType.OUTPUT__UR - + elif re.search("^UR:CRYPTO-ACCOUNT/", s, re.IGNORECASE): return QRType.ACCOUNT__UR @@ -348,10 +366,10 @@ class DecodeQR: elif re.search(r'^\{\"label\".*\"descriptor\"\:.*', desc_str, re.IGNORECASE): # if json starting with label and contains descriptor, assume specter wallet json return QRType.WALLET__SPECTER - + elif "multisig setup file" in s.lower(): return QRType.WALLET__CONFIGFILE - + elif "sortedmulti" in s: return QRType.WALLET__GENERIC @@ -363,8 +381,12 @@ class DecodeQR: elif DecodeQR.is_bitcoin_address(s): return QRType.BITCOIN_ADDRESS + # message signing + elif s.startswith("signmessage"): + return QRType.SIGN_MESSAGE + # config data - if "type=settings" in s: + if s.startswith("settings::"): return QRType.SETTINGS # Seed @@ -374,7 +396,7 @@ class DecodeQR: _4LETTER_WORDLIST = [word[:4].strip() for word in wordlist] except: _4LETTER_WORDLIST = [] - + if all(x in wordlist for x in s.strip().split(" ")): # checks if all words in list are in bip39 word list return QRType.SEED__MNEMONIC @@ -390,7 +412,7 @@ class DecodeQR: # Probably this isn't meant to be string data; check if it's valid byte data # below. pass - + # Is it byte data? # 32 bytes for 24-word CompactSeedQR; 16 bytes for 12-word CompactSeedQR if len(s) == 32 or len(s) == 16: @@ -482,7 +504,8 @@ class DecodeQR: return True else: return False - + + @staticmethod def multisig_setup_file_to_descriptor(text) -> str: # sample text file, parse the contents and create descriptor @@ -578,6 +601,8 @@ class DecodeQR: return descriptor + + class BaseQrDecoder: def __init__(self): self.total_segments = None @@ -590,6 +615,10 @@ class BaseQrDecoder: def add(self, segment, qr_type): raise Exception("Not implemented in child class") + + def get_qr_data(self) -> dict: + # TODO: standardize this approach across all decoders (example: SignMessageQrDecoder) + raise Exception("get_qr_data must be implemented in decoder child class") @@ -673,6 +702,8 @@ class SpecterPsbtQrDecoder(BaseAnimatedQrDecoder): def parse_segment(self, segment) -> str: return segment.split(" ")[-1].strip() + + class Base64PsbtQrDecoder(BaseSingleFrameQrDecoder): """ Decodes single frame base64 encoded qr image. @@ -826,107 +857,68 @@ class SeedQrDecoder(BaseSingleFrameQrDecoder): -# TODO: Refactor this to work with the new SettingsDefinition class SettingsQrDecoder(BaseSingleFrameQrDecoder): + """ + Decodes settings data from the SettingsQR Generator. + """ def __init__(self): super().__init__() - self.settings = {} - self.config_name = None + self.data = None def add(self, segment, qr_type=QRType.SETTINGS): - # print(f"SettingsQR:\n{segment}") - try: - self.settings = {} + """ + * Ignores unrecognized settings options. + * Raises an Exception if a settings value is invalid. - # QR Settings format is space-separated key/value pairs, but should also - # parse \n-separated keys. - for entry in segment.split(): - key = entry.split("=")[0].strip() - value = entry.split("=")[1].strip() - self.settings[key] = value + See `Settings.update()` for info on settings validation, especially for + missing settings. + """ + if not segment.startswith("settings::"): + raise Exception("Invalid SettingsQR data") + + # Leave any other parsing or validation up to the Settings class itself. + # SettingsQR are just ascii data to hand it over as-is. + self.data = segment - # Remove values only needed for import - self.settings.pop("type", None) - version = self.settings.pop("version", None) - if not version or int(version) != 1: - raise Exception(f"Settings QR version {version} not supported") + self.complete = True + self.collected_segments = 1 + return DecodeQRStatus.COMPLETE - self.config_name = self.settings.pop("name", None) - if self.config_name: - self.config_name = self.config_name.replace("_", " ") - - # Have to translate the abbreviated settings into the human-readable values - # used in the normal Settings. - map_abbreviated_enable = { - "0": SettingsConstants.OPTION__DISABLED, - "1": SettingsConstants.OPTION__ENABLED, - "2": SettingsConstants.OPTION__PROMPT, - } - map_abbreviated_sig_types = { - "s": SettingsConstants.SINGLE_SIG, - "m": SettingsConstants.MULTISIG, - } - map_abbreviated_scripts = { - "na": SettingsConstants.NATIVE_SEGWIT, - "ne": SettingsConstants.NESTED_SEGWIT, - "tr": SettingsConstants.TAPROOT, - "cu": SettingsConstants.CUSTOM_DERIVATION, - } - map_abbreviated_coordinators = { - "bw": SettingsConstants.COORDINATOR__BLUE_WALLET, - "sw": SettingsConstants.COORDINATOR__SPARROW, - "sd": SettingsConstants.COORDINATOR__SPECTER_DESKTOP, - } - def convert_abbreviated_value(category, key, abbreviation_map, is_list=False, new_key_name=None): - try: - if key not in self.settings: - print(f"'{key}' not found in settings") - return - value = self.settings[key] - if not is_list: - new_value = abbreviation_map.get(value) - if not new_value: - logger.error(f"No abbreviation map value for \"{value}\" for setting {key}") - return - else: - # `value` is a comma-separated list; yields list of map matches - values = value.split(",") - new_value = [] - for v in values: - mapped_value = abbreviation_map.get(v) - if not mapped_value: - logger.error(f"No abbreviation map value for \"{v}\" for setting {key}") - return - new_value.append(mapped_value) - del self.settings[key] - if new_key_name: - key = new_key_name - if category not in self.settings: - self.settings[category] = {} - self.settings[category][key] = new_value - except Exception as e: - logger.exception(e) - return +class SignMessageQrDecoder(BaseSingleFrameQrDecoder): + def __init__(self): + super().__init__() + self.message = None + self.derivation_path = None - convert_abbreviated_value("wallet", "coord", map_abbreviated_coordinators, is_list=True, new_key_name="coordinators") - convert_abbreviated_value("features", "xpub", map_abbreviated_enable, new_key_name="xpub_export") - convert_abbreviated_value("features", "sigs", map_abbreviated_sig_types, is_list=True, new_key_name="sig_types") - convert_abbreviated_value("features", "scripts", map_abbreviated_scripts, is_list=True, new_key_name="script_types") - convert_abbreviated_value("features", "xp_det", map_abbreviated_enable, new_key_name="show_xpub_details") - convert_abbreviated_value("features", "passphrase", map_abbreviated_enable) - convert_abbreviated_value("features", "priv_warn", map_abbreviated_enable, new_key_name="show_privacy_warnings") - convert_abbreviated_value("features", "dire_warn", map_abbreviated_enable, new_key_name="show_dire_warnings") - self.complete = True - self.collected_segments = 1 - return DecodeQRStatus.COMPLETE - except Exception as e: - logger.exception(e) + def add(self, segment, qr_type=QRType.SIGN_MESSAGE): + """ + Expected QR data format: + + signmessage {derivation_path} ascii:{message} + """ + parts = segment.split() + self.derivation_path = parts[1].replace("h", "'") + fmt = parts[2].split(":")[0] + self.message = segment.split(f"{fmt}:")[1] + + # TODO: support formats other than ascii? + if fmt != "ascii": + logger.info(f"Sign message: Unsupported format: {fmt}") return DecodeQRStatus.INVALID + self.complete = True + self.collected_segments = 1 + + return DecodeQRStatus.COMPLETE + + + def get_qr_data(self) -> dict: + return dict(derivation_path=self.derivation_path, message=self.message) + class BitcoinAddressQrDecoder(BaseSingleFrameQrDecoder): @@ -961,11 +953,12 @@ class BitcoinAddressQrDecoder(BaseSingleFrameQrDecoder): self.address_type = (SettingsConstants.LEGACY_P2PKH, SettingsConstants.TESTNET) elif r == "3": - # Nested Segwit Single Sig (P2WPKH in P2SH) or Multisig (P2WSH in P2SH); mainnet + # 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 == "2": - # Nested Segwit Single Sig (P2WPKH in P2SH) or Multisig (P2WSH in P2SH); testnet + # 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 == "bc1q": @@ -1085,17 +1078,16 @@ class GenericWalletQrDecoder(BaseSingleFrameQrDecoder): self.complete = True return DecodeQRStatus.COMPLETE except Exception as e: - print(repr(e)) + logger.info(repr(e), exc_info=True) return DecodeQRStatus.INVALID def get_wallet_descriptor(self): return self.descriptor - -class MultiSigConfigFileQRDecoder(GenericWalletQrDecoder): - + + + +class MultiSigConfigFileQRDecoder(GenericWalletQrDecoder): def add(self, segment, qr_type=QRType.WALLET__CONFIGFILE): descriptor = DecodeQR.multisig_setup_file_to_descriptor(segment) return super().add(descriptor,qr_type=QRType.WALLET__CONFIGFILE) - - diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index d342597a..65a8f5d1 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -2,7 +2,7 @@ import math from embit import bip32 from embit.networks import NETWORKS -from binascii import b2a_base64, hexlify +from binascii import hexlify from dataclasses import dataclass from typing import List from embit import bip32 @@ -11,225 +11,195 @@ from embit.psbt import PSBT from seedsigner.helpers.ur2.ur_encoder import UREncoder from seedsigner.helpers.ur2.ur import UR from seedsigner.helpers.qr import QR -from seedsigner.models import Seed, QRType +from seedsigner.models.seed import Seed +from seedsigner.models.settings import SettingsConstants from urtypes.crypto import PSBT as UR_PSBT from urtypes.crypto import Account, HDKey, Output, Keypath, PathComponent, SCRIPT_EXPRESSION_TAG_MAP -from seedsigner.models.settings import SettingsConstants - @dataclass -class EncodeQR: - """ - Encode psbt for displaying as qr image - """ - # TODO: Refactor so that this is a base class with implementation classes for each - # QR type. No reason exterior code can't directly instantiate the encoder it needs. - - # Dataclass input vars on __init__() - psbt: PSBT = None - seed_phrase: List[str] = None - passphrase: str = None - derivation: str = None - network: str = SettingsConstants.MAINNET - qr_type: str = None +class BaseQrEncoder: qr_density: str = SettingsConstants.DENSITY__MEDIUM - wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH + def __post_init__(self): self.qr = QR() - if not self.qr_type: - raise Exception('qr_type is required') - if self.qr_density == None: - self.qr_density = SettingsConstants.DENSITY__MEDIUM + @property + def is_complete(self): + raise Exception("Not implemented in child class") - self.encoder: BaseQrEncoder = None + @property + def qr_max_fragment_size(self): + raise Exception("Not implemented in child class") - # PSBT formats - if self.qr_type == QRType.PSBT__SPECTER: - self.encoder = SpecterPsbtQrEncoder(psbt=self.psbt, qr_density=self.qr_density) + def seq_len(self): + raise Exception("Not implemented in child class") - elif self.qr_type == QRType.PSBT__UR2: - self.encoder = UrPsbtQrEncoder(psbt=self.psbt, qr_density=self.qr_density) + def next_part(self) -> str: + raise Exception("Not implemented in child class") + + def cur_part(self) -> str: + raise Exception("Not implemented in child class") + + def restart(self): + # only used by animated QR encoders + pass - # XPUB formats - elif self.qr_type == QRType.XPUB: - self.encoder = XpubQrEncoder( - seed_phrase=self.seed_phrase, - passphrase=self.passphrase, - derivation=self.derivation, - network=self.network, - wordlist_language_code=self.wordlist_language_code - ) - - elif self.qr_type == QRType.XPUB__UR: - self.encoder = UrXpubQrEncoder( - qr_density=self.qr_density, - seed_phrase=self.seed_phrase, - passphrase=self.passphrase, - derivation=self.derivation, - network=self.network, - wordlist_language_code=self.wordlist_language_code - ) - - elif self.qr_type == QRType.XPUB__SPECTER: - self.encoder = SpecterXPubQrEncoder( - qr_density=self.qr_density, - seed_phrase=self.seed_phrase, - passphrase=self.passphrase, - derivation=self.derivation, - network=self.network, - wordlist_language_code=self.wordlist_language_code - ) + def _create_parts(self): + raise Exception("Not implemented in child class") - # SeedQR formats - elif self.qr_type == QRType.SEED__SEEDQR: - self.encoder = SeedQrEncoder(seed_phrase=self.seed_phrase, - wordlist_language_code=self.wordlist_language_code) - - elif self.qr_type == QRType.SEED__COMPACTSEEDQR: - self.encoder = CompactSeedQrEncoder(seed_phrase=self.seed_phrase, - wordlist_language_code=self.wordlist_language_code) - - else: - raise Exception('QR Type not supported') - - - def total_parts(self) -> int: - return self.encoder.seq_len() - - - def next_part(self): - return self.encoder.next_part() - - - def part_to_image(self, part, width=240, height=240, border=3): - return self.qr.qrimage_io(part, width, height, border) + def part_to_image(self, part, width, height, border: int = 3, background_color: str = "ffffff"): + return self.qr.qrimage_io(part, width, height, border, background_color=background_color) def next_part_image(self, width=240, height=240, border=3, background_color="bdbdbd"): part = self.next_part() - if self.qr_type == QRType.SEED__SEEDQR: - return self.qr.qrimage(part, width, height, border) - else: - return self.qr.qrimage_io(part, width, height, border, background_color=background_color) - - - # TODO: Make these properties? - def is_complete(self): - return self.encoder.is_complete - - - def get_qr_density(self): - return self.qr_density - - - def get_qr_type(self): - return self.qr_type + return self.part_to_image(part, width, height, border, background_color=background_color) -class BaseQrEncoder: + +"""************************************************************************************** + STATIC QR encoders +**************************************************************************************""" +@dataclass +class BaseStaticQrEncoder(BaseQrEncoder): def seq_len(self): - raise Exception("Not implemented in child class") + return 1 + + def cur_part(self) -> str: + """ static QRs only have a single part, which `next_part` always returns """ + return self.next_part() - def next_part(self) -> str: - raise Exception("Not implemented in child class") @property def is_complete(self): - raise Exception("Not implemented in child class") - - def _create_parts(self): - raise Exception("Not implemented in child class") + return True -class BasePsbtQrEncoder(BaseQrEncoder): - def __init__(self, psbt: PSBT): - self.psbt = psbt +@dataclass +class SeedQrEncoder(BaseStaticQrEncoder): + mnemonic: List[str] = None + wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH + + + def __post_init__(self): + self.wordlist = Seed.get_wordlist(self.wordlist_language_code) + super().__post_init__() + + self.data = "" + # Output as Numeric data format + for word in self.mnemonic: + index = self.wordlist.index(word) + self.data += str("%04d" % index) + + + def next_part(self): + return self.data -class UrPsbtQrEncoder(BasePsbtQrEncoder): - def __init__(self, psbt, qr_density): - super().__init__(psbt) - self.qr_max_fragment_size = 20 +@dataclass +class CompactSeedQrEncoder(SeedQrEncoder): + def next_part(self): + # Output as binary data format + binary_str = "" + for word in self.mnemonic: + 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: + # 8 checksum bits in a 24-word seed + binary_str = binary_str[:-8] + + elif len(self.mnemonic) == 12: + # 4 checksum bits in a 12-word seed + binary_str = binary_str[:-4] + + # Now convert to bytes, 8 bits at a time + as_bytes = bytearray() + for i in range(0, math.ceil(len(binary_str) / 8)): + # int conversion reads byte data as a string prefixed with '0b' + as_bytes.append(int('0b' + binary_str[i*8:(i+1)*8], 2)) - qr_ur_bytes = UR("crypto-psbt", UR_PSBT(self.psbt.serialize()).to_cbor()) - - if qr_density == SettingsConstants.DENSITY__LOW: - self.qr_max_fragment_size = 10 - elif qr_density == SettingsConstants.DENSITY__MEDIUM: - self.qr_max_fragment_size = 30 - elif qr_density == SettingsConstants.DENSITY__HIGH: - self.qr_max_fragment_size = 120 - - self.ur2_encode = UREncoder(ur=qr_ur_bytes, max_fragment_len=self.qr_max_fragment_size) - - - def seq_len(self): - return self.ur2_encode.fountain_encoder.seq_len() - - - def next_part(self) -> str: - return self.ur2_encode.next_part().upper() - - - @property - def is_complete(self): - return self.ur2_encode.is_complete() + # Must return data as `bytes` for `qrcode` to properly recognize it as byte data + return bytes(as_bytes) -class SpecterPsbtQrEncoder(BasePsbtQrEncoder): - def __init__(self, psbt, qr_density): - super().__init__(psbt) - self.qr_max_fragement_size = 65 +@dataclass +class GenericStaticQrEncoder(BaseStaticQrEncoder): + data: str = None + + def next_part(self): + return self.data + + + +@dataclass +class BaseXpubQrEncoder(BaseQrEncoder): + """ + Base Xpub QrEncoder for static and animated formats + """ + seed: Seed = None + derivation: str = None + network: str = SettingsConstants.MAINNET + sig_type : str = None + + def prep_xpub(self): + + 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) + self.xpub = self.xprv.to_public() + self.xpub_base58 = self.xpub.to_string(version=version) + + self.xpubstring = "[{}{}]{}".format( + hexlify(self.fingerprint).decode('utf-8'), + self.derivation[1:], + self.xpub_base58 + ) + + + +class StaticXpubQrEncoder(BaseXpubQrEncoder, BaseStaticQrEncoder): + def __post_init__(self): + super().__post_init__() + self.prep_xpub() + + + def next_part(self): + self.prep_xpub() + return self.xpubstring + + + +"""************************************************************************************** + Simple animated QR encoders +**************************************************************************************""" +@dataclass +class BaseSimpleAnimatedQREncoder(BaseQrEncoder): + def __post_init__(self): + super().__post_init__() self.parts = [] self.part_num_sent = 0 self.sent_complete = False - - if qr_density == SettingsConstants.DENSITY__LOW: - self.qr_max_fragement_size = 40 - elif qr_density == SettingsConstants.DENSITY__MEDIUM: - self.qr_max_fragement_size = 65 - elif qr_density == SettingsConstants.DENSITY__HIGH: - self.qr_max_fragement_size = 90 - self._create_parts() - def _create_parts(self): - base64_psbt = b2a_base64(self.psbt.serialize()) - - if base64_psbt[-1:] == b"\n": - base64_psbt = base64_psbt[:-1] - - base64_psbt = base64_psbt.decode('utf-8') - - start = 0 - stop = self.qr_max_fragement_size - qr_cnt = ((len(base64_psbt)-1) // self.qr_max_fragement_size) + 1 - - if qr_cnt == 1: - self.parts.append(base64_psbt[start:stop]) - - cnt = 0 - while cnt < qr_cnt and qr_cnt != 1: - part = "p" + str(cnt+1) + "of" + str(qr_cnt) + " " + base64_psbt[start:stop] - self.parts.append(part) - - start = start + self.qr_max_fragement_size - stop = stop + self.qr_max_fragement_size - if stop > len(base64_psbt): - stop = len(base64_psbt) - cnt += 1 + @property + def is_complete(self): + return self.sent_complete def seq_len(self): @@ -253,142 +223,34 @@ class SpecterPsbtQrEncoder(BasePsbtQrEncoder): return part - @property - def is_complete(self): - return self.sent_complete + def cur_part(self) -> str: + if self.part_num_sent == 0: + # Rewind all the way back to the end + self.part_num_sent = len(self.parts) - 1 + else: + self.part_num_sent -= 1 + return self.next_part() - -class SeedQrEncoder(BaseQrEncoder): - def __init__(self, seed_phrase: List[str], wordlist_language_code: str): - super().__init__() - self.seed_phrase = seed_phrase - self.wordlist = Seed.get_wordlist(wordlist_language_code) - - if self.wordlist == None: - raise Exception('Wordlist Required') - - - def seq_len(self): - return 1 - - - def next_part(self): - data = "" - # Output as Numeric data format - for word in self.seed_phrase: - index = self.wordlist.index(word) - data += str("%04d" % index) - return data - - - @property - def is_complete(self): - return True - - - -class CompactSeedQrEncoder(SeedQrEncoder): - def next_part(self): - # Output as binary data format - binary_str = "" - for word in self.seed_phrase: - 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.seed_phrase) == 24: - # 8 checksum bits in a 24-word seed - binary_str = binary_str[:-8] - - elif len(self.seed_phrase) == 12: - # 4 checksum bits in a 12-word seed - binary_str = binary_str[:-4] - - # Now convert to bytes, 8 bits at a time - as_bytes = bytearray() - for i in range(0, math.ceil(len(binary_str) / 8)): - # int conversion reads byte data as a string prefixed with '0b' - as_bytes.append(int('0b' + binary_str[i*8:(i+1)*8], 2)) - - # Must return data as `bytes` for `qrcode` to properly recognize it as byte data - return bytes(as_bytes) - - - -class XpubQrEncoder(BaseQrEncoder): - def __init__(self, seed_phrase, passphrase, derivation, network, wordlist_language_code): - self.seed_phrase = seed_phrase - self.passphrase = passphrase - self.derivation = derivation - self.network = network - self.wordlist = Seed.get_wordlist(wordlist_language_code) - self.parts = [] + def restart(self) -> str: self.part_num_sent = 0 - self.sent_complete = False - - 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.seed_phrase, - passphrase=self.passphrase, - wordlist_language_code=wordlist_language_code) - 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) - self.xpub = self.xprv.to_public() - self.xpub_base58 = self.xpub.to_string(version=version) - - self.xpubstring = "[{}{}]{}".format( - hexlify(self.fingerprint).decode('utf-8'), - self.derivation[1:], - self.xpub_base58 - ) - - self._create_parts() - def _create_parts(self): - self.parts = [] - self.parts.append(self.xpubstring) - - - def next_part(self) -> str: - if len(self.parts) > 0: - self.sent_complete = True - return self.parts[0] - - - def seq_len(self): - return len(self.parts) - +@dataclass +class SpecterXPubQrEncoder(BaseSimpleAnimatedQREncoder, BaseXpubQrEncoder): @property - def is_complete(self): - return self.sent_complete - - - -class SpecterXPubQrEncoder(XpubQrEncoder): - def __init__(self, qr_density, **kwargs): - # Must set up qr_max_fragment_size before calling super().__init__() - self.qr_max_fragment_size = 65 - if qr_density == SettingsConstants.DENSITY__LOW: - self.qr_max_fragment_size = 40 - elif qr_density == SettingsConstants.DENSITY__MEDIUM: - self.qr_max_fragment_size = 65 - elif qr_density == SettingsConstants.DENSITY__HIGH: - self.qr_max_fragment_size = 90 - - super().__init__(**kwargs) + def qr_max_fragment_size(self): + density_mapping = { + SettingsConstants.DENSITY__LOW: 40, + SettingsConstants.DENSITY__MEDIUM: 65, + SettingsConstants.DENSITY__HIGH: 90, + } + return density_mapping.get(self.qr_density, 65) def _create_parts(self): - self.parts = [] - + self.prep_xpub() start = 0 stop = self.qr_max_fragment_size qr_cnt = ((len(self.xpubstring)-1) // self.qr_max_fragment_size) + 1 @@ -408,34 +270,60 @@ class SpecterXPubQrEncoder(XpubQrEncoder): cnt += 1 + +"""************************************************************************************** + Fountain encoded animated QR encoders +**************************************************************************************""" +@dataclass +class BaseFountainQrEncoder(BaseQrEncoder): + def __post_init__(self): + super().__post_init__() + + self.ur2_encode: UREncoder = None + + + @property + def is_complete(self): + return self.ur2_encode.is_complete() + + + @property + def qr_max_fragment_size(self): + density_mapping = { + SettingsConstants.DENSITY__LOW: 10, + SettingsConstants.DENSITY__MEDIUM: 30, + SettingsConstants.DENSITY__HIGH: 120, + } + return density_mapping.get(self.qr_density, 30) + + + def _create_parts(self): + """ parts are dynamically generated by the fountain encoder """ + pass + + + def seq_len(self): + return self.ur2_encode.fountain_encoder.seq_len() + + def next_part(self) -> str: - # if part num sent is gt number of parts, start at 0 - if self.part_num_sent > (len(self.parts) - 1): - self.part_num_sent = 0 + return self.ur2_encode.next_part().upper() - part = self.parts[self.part_num_sent] - # when parts sent eq num of parts in list - if self.part_num_sent == (len(self.parts) - 1): - self.sent_complete = True + def cur_part(self) -> str: + return self.ur2_encode.current_part().upper() + - # increment to next part - self.part_num_sent += 1 - - return part + def restart(self): + self.ur2_encode.fountain_encoder.restart() -class UrXpubQrEncoder(XpubQrEncoder): - def __init__(self, qr_density, **kwargs): - super().__init__(**kwargs) - - if qr_density == SettingsConstants.DENSITY__LOW: - self.qr_max_fragment_size = 10 - elif qr_density == SettingsConstants.DENSITY__MEDIUM: - self.qr_max_fragment_size = 30 - elif qr_density == SettingsConstants.DENSITY__HIGH: - self.qr_max_fragment_size = 120 +@dataclass +class UrXpubQrEncoder(BaseFountainQrEncoder, BaseXpubQrEncoder): + def __post_init__(self): + super().__post_init__() + self.prep_xpub() def derivation_to_keypath(path: str) -> list: arr = path.split("/") @@ -475,6 +363,12 @@ class UrXpubQrEncoder(XpubQrEncoder): ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[401]],self.ur_hdkey)) elif origin.components[3].index == 1: # Nested Multisig ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[400], SCRIPT_EXPRESSION_TAG_MAP[401]],self.ur_hdkey)) + elif origin.components[0].index == 86: # P2TR + ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[409]],self.ur_hdkey)) + elif origin.components[0].index == 44: # P2PKH + ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[403]],self.ur_hdkey)) + elif origin.components[0].index == 45: # P2SH + ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[400]],self.ur_hdkey)) # If empty, add all script types if len(ur_outputs) == 0: @@ -483,6 +377,7 @@ class UrXpubQrEncoder(XpubQrEncoder): ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[401]],self.ur_hdkey)) ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[400], SCRIPT_EXPRESSION_TAG_MAP[401]],self.ur_hdkey)) ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[403]],self.ur_hdkey)) + ur_outputs.append(Output([SCRIPT_EXPRESSION_TAG_MAP[400]],self.ur_hdkey)) ur_account = Account(self.root.my_fingerprint, ur_outputs) @@ -491,9 +386,12 @@ class UrXpubQrEncoder(XpubQrEncoder): self.ur2_encode = UREncoder(ur=qr_ur_bytes, max_fragment_len=self.qr_max_fragment_size) - def seq_len(self): - return self.ur2_encode.fountain_encoder.seq_len() +@dataclass +class UrPsbtQrEncoder(BaseFountainQrEncoder): + psbt: PSBT = None - def next_part(self) -> str: - return self.ur2_encode.next_part().upper() + def __post_init__(self): + super().__post_init__() + qr_ur_bytes = UR("crypto-psbt", UR_PSBT(self.psbt.serialize()).to_cbor()) + self.ur2_encode = UREncoder(ur=qr_ur_bytes, max_fragment_len=self.qr_max_fragment_size) diff --git a/src/seedsigner/models/psbt_parser.py b/src/seedsigner/models/psbt_parser.py index af29a882..7aa99538 100644 --- a/src/seedsigner/models/psbt_parser.py +++ b/src/seedsigner/models/psbt_parser.py @@ -1,14 +1,21 @@ +import logging from binascii import hexlify -from embit import psbt, script, ec, bip32, bip39 +from embit import psbt, script, ec, bip32 from embit.descriptor import Descriptor from embit.networks import NETWORKS from embit.psbt import PSBT from io import BytesIO from typing import List -from seedsigner.models import Seed +from seedsigner.models.seed import Seed from seedsigner.models.settings import SettingsConstants +logger = logging.getLogger(__name__) + +class OPCODES: + OP_RETURN = 106 + OP_PUSHDATA1 = 76 + class PSBTParser(): @@ -26,6 +33,7 @@ class PSBTParser(): self.num_inputs = 0 self.destination_addresses = [] self.destination_amounts = [] + self.op_return_data: bytes = None self.root = None @@ -62,11 +70,11 @@ class PSBTParser(): def parse(self): if self.psbt is None: - print(f"self.psbt is None!!") + logger.info(f"self.psbt is None!!") return False if not self.seed: - print("self.seed is None!") + logger.info("self.seed is None!") return False self._set_root() @@ -88,13 +96,17 @@ class PSBTParser(): for inp in self.psbt.inputs: if inp.witness_utxo: self.input_amount += inp.witness_utxo.value - inp_policy = PSBTParser._get_policy(inp, inp.witness_utxo.script_pubkey, self.psbt.xpubs) - if self.policy == None: - self.policy = inp_policy - else: - if self.policy != inp_policy: - raise RuntimeError("Mixed inputs in the transaction") + script_pubkey = inp.witness_utxo.script_pubkey + elif inp.non_witness_utxo: + self.input_amount += inp.utxo.value + script_pubkey = inp.script_pubkey + inp_policy = PSBTParser._get_policy(inp, script_pubkey, self.psbt.xpubs) + if self.policy == None: + self.policy = inp_policy + else: + if self.policy != inp_policy: + raise RuntimeError("Mixed inputs in the transaction") def _parse_outputs(self): self.spend_amount = 0 @@ -118,39 +130,80 @@ class PSBTParser(): # empty script by default sc = script.Script(b"") + + # if older multisig, just use existing script + if self.policy["type"] == "p2sh": + sc = script.p2sh(out.redeem_script) + # multisig, we know witness script if self.policy["type"] == "p2wsh": sc = script.p2wsh(out.witness_script) + elif self.policy["type"] == "p2sh-p2wsh": sc = script.p2sh(script.p2wsh(out.witness_script)) + + # Arbitrary p2sh; includes pre-segwit multisig (m/45') + elif self.policy["type"] == "p2sh": + sc = script.p2sh(out.redeem_script) # single-sig elif "pkh" in self.policy["type"]: my_pubkey = None + # should be one or zero for single-key addresses if len(out.bip32_derivations.values()) > 0: der = list(out.bip32_derivations.values())[0].derivation my_pubkey = self.root.derive(der) - if self.policy["type"] == "p2wpkh" and my_pubkey is not None: - sc = script.p2wpkh(my_pubkey) + + if self.policy["type"] == "p2pkh" and my_pubkey is not None: + sc = script.p2pkh(my_pubkey) + elif self.policy["type"] == "p2sh-p2wpkh" and my_pubkey is not None: sc = script.p2sh(script.p2wpkh(my_pubkey)) + elif self.policy["type"] == "p2wpkh" and my_pubkey is not None: + sc = script.p2wpkh(my_pubkey) + + if sc.data == self.psbt.tx.vout[i].script_pubkey.data: + is_change = True + + elif "p2tr" in self.policy["type"]: + my_pubkey = None + # should have one or zero derivations for single-key addresses + if len(out.taproot_bip32_derivations.values()) > 0: + # TODO: Support keys in taptree leaves + leaf_hashes, derivation = list(out.taproot_bip32_derivations.values())[0] + der = derivation.derivation + my_pubkey = self.root.derive(der) + sc = script.p2tr(my_pubkey) + if sc.data == self.psbt.tx.vout[i].script_pubkey.data: is_change = True 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 = None - derivation_paths = None + fingerprints = [] + derivation_paths = [] + + # extract info from non-taproot outputs if len(self.psbt.outputs[i].bip32_derivations) > 0: - fingerprints = [] - derivation_paths = [] for d, derivation_path in self.psbt.outputs[i].bip32_derivations.items(): fingerprints.append(hexlify(derivation_path.fingerprint).decode()) derivation_paths.append(bip32.path_to_str(derivation_path.derivation)) + + # extract info from taproot outputs + if len(self.psbt.outputs[i].taproot_bip32_derivations) > 0: + for d, (leaf_hashes, derivation) in self.psbt.outputs[i].taproot_bip32_derivations.items(): + fingerprints.append(hexlify(derivation.fingerprint).decode()) + derivation_paths.append(bip32.path_to_str(derivation.derivation)) + self.change_data.append({ "output_index": i, "address": addr, @@ -159,6 +212,7 @@ class PSBTParser(): "derivation_path": derivation_paths, }) self.change_amount += self.psbt.tx.vout[i].value + else: addr = self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[SettingsConstants.map_network_to_embit(self.network)]) self.destination_addresses.append(addr) @@ -172,10 +226,14 @@ class PSBTParser(): @staticmethod def trim(tx): trimmed_psbt = psbt.PSBT(tx.tx) - sigsEnd = 0 for i, inp in enumerate(tx.inputs): - sigsEnd += len(list(inp.partial_sigs.keys())) - trimmed_psbt.inputs[i].partial_sigs = inp.partial_sigs + if inp.final_scriptwitness: + # Taproot sign; trim to only final_scriptwitness + # From BIP-371 and BIP-174, once final script witness is populated + # it contains all necessary signatures + trimmed_psbt.inputs[i].final_scriptwitness = inp.final_scriptwitness + else: + trimmed_psbt.inputs[i].partial_sigs = inp.partial_sigs return trimmed_psbt @@ -184,47 +242,15 @@ class PSBTParser(): def sig_count(tx): cnt = 0 for i, inp in enumerate(tx.inputs): - cnt += len(list(inp.partial_sigs.keys())) + if inp.final_scriptwitness is not None: + # Taproot sign + cnt += 1 + else: + cnt += len(list(inp.partial_sigs.keys())) return cnt - @staticmethod - def calc_derivation(network, wallet_type, script_type): - # TODO: Move this to Seed? - if network == SettingsConstants.MAINNET: - network_path = "0'" - elif network == SettingsConstants.TESTNET: - network_path = "1'" - elif network == SettingsConstants.REGTEST: - # TODO: Is this right? - network_path = "1'" - else: - raise Exception("Unexpected network") - - if wallet_type == SettingsConstants.SINGLE_SIG: - if script_type == SettingsConstants.NATIVE_SEGWIT: - return f"m/84'/{network_path}/0'" - elif script_type == SettingsConstants.NESTED_SEGWIT: - return f"m/49'/{network_path}/0'" - elif script_type == SettingsConstants.TAPROOT: - return f"m/86'/{network_path}/0'" - else: - raise Exception("Unexpected script type") - - elif wallet_type == SettingsConstants.MULTISIG: - if script_type == SettingsConstants.NATIVE_SEGWIT: - return f"m/48'/{network_path}/0'/2'" - elif script_type == SettingsConstants.NESTED_SEGWIT: - return f"m/48'/{network_path}/0'/1'" - elif script_type == SettingsConstants.TAPROOT: - raise Exception("Taproot multisig/musig not yet supported") - else: - raise Exception("Unexpected script type") - else: - raise Exception("Unexpected wallet type") # checks that all inputs are from the same wallet - - @staticmethod def _get_policy(scope, scriptpubkey, xpubs): """Parse scope and get policy""" @@ -241,15 +267,26 @@ class PSBTParser(): ): script_type = "p2sh-p2wpkh" policy = {"type": script_type} + # expected multisig - if "p2wsh" in script_type and scope.witness_script is not None: - m, n, pubkeys = PSBTParser._parse_multisig(scope.witness_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}) + script = None + if script_type: + if "p2wsh" in script_type and scope.witness_script is not None: + script = scope.witness_script + + elif "p2sh" == 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}) + return policy @@ -319,6 +356,12 @@ class PSBTParser(): for input in psbt.inputs: for pub, derivation_path in input.bip32_derivations.items(): fingerprints.add(hexlify(derivation_path.fingerprint).decode()) + + for pub, (leaf_hashes, derivation_path) in input.taproot_bip32_derivations.items(): + # TODO: Support spends from leaves; depends on support in embit + if len(leaf_hashes) > 0: + raise Exception("Signing keyspends from within a taptree not yet implemented") + fingerprints.add(hexlify(derivation_path.fingerprint).decode()) return list(fingerprints) @@ -333,6 +376,10 @@ class PSBTParser(): for pub, derivation_path in input.bip32_derivations.items(): if seed_fingerprint == hexlify(derivation_path.fingerprint).decode(): return True + + for pub, (leaf_hashes, derivation_path) in input.taproot_bip32_derivations.items(): + if seed_fingerprint == hexlify(derivation_path.fingerprint).decode(): + return True return False diff --git a/src/seedsigner/models/qr_type.py b/src/seedsigner/models/qr_type.py index 7ef0ca67..6ec1f5ec 100644 --- a/src/seedsigner/models/qr_type.py +++ b/src/seedsigner/models/qr_type.py @@ -21,6 +21,8 @@ class QRType: BITCOIN_ADDRESS = "bitcoin_address" + SIGN_MESSAGE = "sign_message" + WALLET__SPECTER = "wallet__specter" WALLET__UR = "wallet__ur" WALLET__CONFIGFILE = "wallet__configfile" diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index 43a2f4d8..2218ae6c 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -1,12 +1,16 @@ +import logging import unicodedata +import hashlib +import hmac from binascii import hexlify -from embit import bip39, bip32 +from embit import bip39, bip32, bip85 from embit.networks import NETWORKS from typing import List from seedsigner.models.settings import SettingsConstants +logger = logging.getLogger(__name__) class InvalidSeedException(Exception): @@ -19,7 +23,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]") @@ -34,18 +38,18 @@ class Seed: @staticmethod def get_wordlist(wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> List[str]: - # TODO: Support other bip-39 wordlist languages! + # TODO: Support other BIP-39 wordlist languages! if wordlist_language_code == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH: return bip39.WORDLIST else: raise Exception(f"Unrecognized wordlist_language_code {wordlist_language_code}") - def _generate_seed(self) -> bool: + def _generate_seed(self): try: self.seed_bytes = bip39.mnemonic_to_seed(self.mnemonic_str, password=self._passphrase, wordlist=self.wordlist) except Exception as e: - print(repr(e)) + logger.info(repr(e), exc_info=True) raise InvalidSeedException(repr(e)) @@ -57,7 +61,12 @@ 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: @@ -98,22 +107,130 @@ class Seed: def set_wordlist_language_code(self, language_code: str): - # TODO: Support other bip-39 wordlist languages! + # TODO: Support other BIP-39 wordlist languages! raise Exception("Not yet implemented!") + @property + def script_override(self) -> str: + return None + + + def derivation_override(self, sig_type: str = SettingsConstants.SINGLE_SIG) -> str: + return None + + + def detect_version(self, derivation_path: str, network: str = SettingsConstants.MAINNET, sig_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"]) return hexlify(root.child(0).fingerprint).decode('utf-8') - + + def get_xpub(self, wallet_path: str = '/', network: str = SettingsConstants.MAINNET): + # Import here to avoid slow startup times; takes 1.35s to import the first time + from seedsigner.helpers import embit_utils + return embit_utils.get_xpub(seed_bytes=self.seed_bytes, derivation_path=wallet_path, embit_network=SettingsConstants.map_network_to_embit(network)) + + + def get_bip85_child_mnemonic(self, bip85_index: int, bip85_num_words: int, network: str = SettingsConstants.MAINNET): + """Derives the seed's nth BIP-85 child mnemonic""" root = bip32.HDKey.from_seed(self.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(network)]["xprv"]) - xprv = root.derive(wallet_path) - xpub = xprv.to_public() - return xpub - + + # TODO: Support other BIP-39 wordlist languages! + return bip85.derive_mnemonic(root, bip85_num_words, bip85_index) + + ### override operators def __eq__(self, other): if isinstance(other, Seed): return self.seed_bytes == other.seed_bytes return False + + + +class ElectrumSeed(Seed): + + def _generate_seed(self): + if len(self._mnemonic) != 12: + raise InvalidSeedException(f"Unsupported Electrum seed length: {len(self._mnemonic)}") + + 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) + + else: + raise InvalidSeedException(f"Unsupported Electrum seed format: {prefix}") + + + 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) -> str: + return SettingsConstants.NATIVE_SEGWIT + + + def derivation_override(self, sig_type: str = SettingsConstants.SINGLE_SIG) -> str: + return "m/0h" if sig_type == SettingsConstants.SINGLE_SIG else "m/1h" + + + def detect_version(self, derivation_path: str, network: str = SettingsConstants.MAINNET, sig_type: str = SettingsConstants.SINGLE_SIG) -> str: + embit_network = NETWORKS[SettingsConstants.map_network_to_embit(network)] + return embit_network["zpub"] if sig_type == SettingsConstants.SINGLE_SIG 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 d2881e32..85feb49c 100644 --- a/src/seedsigner/models/seed_storage.py +++ b/src/seedsigner/models/seed_storage.py @@ -1,6 +1,5 @@ from typing import List -from seedsigner.models import Seed -from seedsigner.models.seed import InvalidSeedException +from seedsigner.models.seed import Seed, ElectrumSeed, InvalidSeedException from seedsigner.models.settings_definition import SettingsConstants @@ -10,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): @@ -59,11 +59,17 @@ 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): + """ + Replaces the nth word in the pending mnemonic. + + * may specify a negative `index` (e.g. -1 is the last word). + """ if index >= len(self._pending_mnemonic): raise Exception(f"index {index} is too high") self._pending_mnemonic[index] = word @@ -77,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.py b/src/seedsigner/models/settings.py index 2b57e45e..ae5b4ffe 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -1,18 +1,26 @@ +import logging import json import os +import platform -from typing import Any, List +from typing import List from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition +from seedsigner.models.singleton import Singleton -from .singleton import Singleton +logger = logging.getLogger(__name__) +class InvalidSettingsQRData(Exception): + pass + class Settings(Singleton): - SETTINGS_FILENAME = "settings.json" - + HOSTNAME = platform.uname()[1] + SEEDSIGNER_OS = "seedsigner-os" + SETTINGS_FILENAME = "/mnt/microsd/settings.json" if HOSTNAME == SEEDSIGNER_OS else "settings.json" + @classmethod def get_instance(cls): # This is the only way to access the one and only instance @@ -26,11 +34,75 @@ class Settings(Singleton): # Read persistent settings file, if it exists if os.path.exists(Settings.SETTINGS_FILENAME): with open(Settings.SETTINGS_FILENAME) as settings_file: - settings.update(json.load(settings_file), disable_missing_entries=False) + settings.update(json.load(settings_file)) return cls._instance + @classmethod + def parse_settingsqr(cls, data: str) -> tuple[str, dict]: + """ + Parses SettingsQR data and returns a tuple of (config_name, settings_dict). + + The resulting settings config can be applied by calling `Settings.update(settings_dict)`. + """ + if not data.startswith("settings::"): + raise InvalidSettingsQRData() + + version = data.split()[0].split("::")[1] + if version != "v1": + raise InvalidSettingsQRData(f"Unsupported SettingsQR version: {version}") + + # Start parsing key/value settings at the nth split() index + split_index = 1 + + # handle optional "name" attr + config_name = None + if "name=" in data.split()[1]: + config_name = data.split("name=")[1].split()[0].replace("_", " ") + split_index += 1 + + updated_settings = {} + for entry in data.split()[split_index:]: + abbreviated_name, value = entry.split("=") + + # Parse multi-value settings; integer-ize where needed + if "," in value: + values_updated = [] + for v in value.split(","): + if v.isdigit(): + v = int(v) + values_updated.append(v) + value = values_updated + elif value.isdigit(): + value = int(value) + + # Replace abbreviated name with full attr_name + settings_entry = SettingsDefinition.get_settings_entry_by_abbreviated_name(abbreviated_name) + if not settings_entry: + logger.info(f"Ignoring unrecognized attribute: {abbreviated_name}") + continue + + # Validate value(s) against SettingsDefinition's valid options + if type(value) is not list: + values = [value] + else: + values = value + for v in values: + if v not in [opt[0] for opt in settings_entry.selection_options]: + if settings_entry.attr_name == SettingsConstants.SETTING__PERSISTENT_SETTINGS and v == SettingsConstants.OPTION__ENABLED: + # Special case: trying to enable Persistent Settings when + # DISABLED is the only option allowed (because the SD card is not + # inserted. Explicitly set to DISABLED. + value = SettingsConstants.OPTION__DISABLED + break + raise InvalidSettingsQRData(f"""{abbreviated_name} = '{v}' is not valid""") + + updated_settings[settings_entry.attr_name] = value + + return (config_name, updated_settings) + + def __str__(self): return json.dumps(self._data, indent=4) @@ -39,34 +111,28 @@ class Settings(Singleton): if self._data[SettingsConstants.SETTING__PERSISTENT_SETTINGS] == SettingsConstants.OPTION__ENABLED: with open(Settings.SETTINGS_FILENAME, 'w') as settings_file: json.dump(self._data, settings_file, indent=4) + # SeedSignerOS makes removing the microsd possible, flush and then fsync forces persistent settings to disk + # without this, recent settings changes could be missing after the microsd card was removed + settings_file.flush() + os.fsync(settings_file.fileno()) - def update(self, new_settings: dict, disable_missing_entries: bool = True): + def update(self, new_settings: dict): """ - * disable_missing_entries: The SettingsQR Generator omits any multiselect - fields with zero selections or disabled Enabled/Disabled toggles. So if a - field is missing, interpret it as such. But if this is set to False, keep - the existing value for the field; most likely this is a new setting that - the user may not have a value for when loading their persistent settings, - in which case this would preserve the new field's default value. + Replaces the current settings with the incoming dict. + + If a setting is missing from `new_settings`: + * Hidden settings that have a value remain as-is. + * All other missing settings are set to their default value. """ for entry in SettingsDefinition.settings_entries: if entry.attr_name not in new_settings: - if not disable_missing_entries: + if entry.visibility == SettingsConstants.VISIBILITY__HIDDEN and entry.attr_name in self._data: + # Preserve existing hidden values + new_settings[entry.attr_name] = self._data[entry.attr_name] + else: # Setting is missing; insert default new_settings[entry.attr_name] = entry.default_value - - elif entry.visibility == SettingsConstants.VISIBILITY__HIDDEN: - # Missing hidden values always get their default - new_settings[entry.attr_name] = entry.default_value - - elif entry.type == SettingsConstants.TYPE__MULTISELECT: - # Clear out the multiselect - new_settings[entry.attr_name] = [] - - elif entry.type in SettingsConstants.ALL_ENABLED_DISABLED_TYPES: - # Set DISABLED for this missing setting - new_settings[entry.attr_name] = SettingsConstants.OPTION__DISABLED else: # Clean the incoming data, if necessary @@ -74,9 +140,6 @@ class Settings(Singleton): if type(new_settings[entry.attr_name]) == str: # Break comma-separated SettingsQR input into List new_settings[entry.attr_name] = new_settings[entry.attr_name].split(",") - - # TODO: If value is not in entry.selection_options... - # Can't just merge the _data dict; have to replace keys they have in common # (otherwise list values will be merged instead of replaced). @@ -100,9 +163,12 @@ class Settings(Singleton): # Special handling for toggling persistence if attr_name == SettingsConstants.SETTING__PERSISTENT_SETTINGS and value == SettingsConstants.OPTION__DISABLED: - os.remove(self.SETTINGS_FILENAME) - print(f"Removed {self.SETTINGS_FILENAME}") - + try: + os.remove(self.SETTINGS_FILENAME) + logger.info(f"Removed {self.SETTINGS_FILENAME}") + except: + logger.info(f"{self.SETTINGS_FILENAME} not found to be removed") + self._data[attr_name] = value self.save() @@ -148,12 +214,14 @@ class Settings(Singleton): raise Exception(f"Unsupported SettingsEntry.type: {settings_entry.type}") display_names = [] - for value in self._data[attr_name]: - display_names.append(settings_entry.get_selection_option_display_name_by_value(value)) + # Iterate through the selection_options list in order to preserve intended sort + # order when adding which options are selected. + for value, display_name in settings_entry.selection_options: + if value in self._data[attr_name]: + display_names.append(display_name) return display_names - """ Intentionally keeping the properties very limited to avoid an expectation of boilerplate property code for every SettingsEntry. @@ -167,3 +235,39 @@ class Settings(Singleton): return self._data[SettingsConstants.SETTING__DEBUG] == SettingsConstants.OPTION__ENABLED + def handle_microsd_state_change(action: str): + """ + Enables/Disables the Persistent Settings option based on the MicroSD card state. + """ + from seedsigner.hardware.microsd import MicroSD + + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + if action == MicroSD.ACTION__INSERTED: + # SD card was just inserted. + # Restore persistent settings back to defaults + entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS) + entry.selection_options = SettingsConstants.OPTIONS__ENABLED_DISABLED + entry.help_text = SettingsConstants.PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT + + # TODO: Perhaps prompt the user if the current settings (not including persistent + # settings) should overwrite the settings on disk, if they differ: + # - Overwrite settings on the SD? + # - Load settings from SD? + # if Settings file exists (meaning persistent settings was previously enabled), write out current settings to disk + if os.path.exists(Settings.SETTINGS_FILENAME): + # enable persistent settings first, then save + Settings.get_instance()._data[SettingsConstants.SETTING__PERSISTENT_SETTINGS] = SettingsConstants.OPTION__ENABLED + Settings.get_instance().save() + + elif action == MicroSD.ACTION__REMOVED: + # SD card was just removed. + # Set persistent settings to disabled value directly + Settings.get_instance()._data[SettingsConstants.SETTING__PERSISTENT_SETTINGS] = SettingsConstants.OPTION__DISABLED + + # set persistent settings to only have disabled as an option, adding additional help text that microSD is removed + entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS) + entry.selection_options = SettingsConstants.OPTIONS__ONLY_DISABLED + entry.help_text = SettingsConstants.PERSISTENT_SETTINGS__SD_REMOVED__HELP_TEXT + + else: + raise Exception(f"Invalid MicroSD action: {action}") diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index f1f12af6..089a94b2 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -13,6 +13,9 @@ class SettingsConstants: (OPTION__ENABLED, "Enabled"), (OPTION__DISABLED, "Disabled"), ] + OPTIONS__ONLY_DISABLED = [ + (OPTION__DISABLED, "Disabled"), + ] OPTIONS__PROMPT_REQUIRED_DISABLED = [ (OPTION__PROMPT, "Prompt"), (OPTION__REQUIRED, "Required"), @@ -33,11 +36,13 @@ class SettingsConstants: COORDINATOR__NUNCHUK = "nun" COORDINATOR__SPARROW = "spa" COORDINATOR__SPECTER_DESKTOP = "spd" + COORDINATOR__KEEPER = "kpr" ALL_COORDINATORS = [ (COORDINATOR__BLUE_WALLET, "BlueWallet"), (COORDINATOR__NUNCHUK, "Nunchuk"), (COORDINATOR__SPARROW, "Sparrow"), (COORDINATOR__SPECTER_DESKTOP, "Specter Desktop"), + (COORDINATOR__KEEPER, "Keeper"), ] LANGUAGE__ENGLISH = "en" @@ -50,10 +55,10 @@ class SettingsConstants: BTC_DENOMINATION__THRESHOLD = "thr" BTC_DENOMINATION__BTCSATSHYBRID = "hyb" ALL_BTC_DENOMINATIONS = [ - (BTC_DENOMINATION__BTC, "Btc-only"), - (BTC_DENOMINATION__SATS, "Sats-only"), + (BTC_DENOMINATION__BTC, "BTC"), + (BTC_DENOMINATION__SATS, "sats"), (BTC_DENOMINATION__THRESHOLD, "Threshold at 0.01"), - (BTC_DENOMINATION__BTCSATSHYBRID, "Btc | Sats hybrid"), + (BTC_DENOMINATION__BTCSATSHYBRID, "BTC | sats hybrid"), ] CAMERA_ROTATION__0 = 0 @@ -96,6 +101,8 @@ class SettingsConstants: if network == SettingsConstants.REGTEST: return "regtest" + PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT = "Store Settings on SD card" + PERSISTENT_SETTINGS__SD_REMOVED__HELP_TEXT = "Insert SD card to enable" SINGLE_SIG = "ss" MULTISIG = "ms" @@ -104,14 +111,15 @@ class SettingsConstants: (MULTISIG, "Multisig"), ] - LEGACY_P2PKH = "leg" # Intentionally excluded from ALL_SCRIPT_TYPES + LEGACY_P2PKH = "leg" NATIVE_SEGWIT = "nat" NESTED_SEGWIT = "nes" TAPROOT = "tr" CUSTOM_DERIVATION = "cus" ALL_SCRIPT_TYPES = [ (NATIVE_SEGWIT, "Native Segwit"), - (NESTED_SEGWIT, "Nested Segwit (legacy)"), + (NESTED_SEGWIT, "Nested Segwit"), + (LEGACY_P2PKH, "Legacy"), (TAPROOT, "Taproot"), (CUSTOM_DERIVATION, "Custom Derivation"), ] @@ -152,8 +160,12 @@ class SettingsConstants: SETTING__PASSPHRASE = "passphrase" 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" + SETTING__QR_BRIGHTNESS_TIPS = "qr_brightness_tips" SETTING__PARTNER_LOGOS = "partner_logos" SETTING__DEBUG = "debug" @@ -188,6 +200,17 @@ 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" # Terminology used by Electrum seeds + + @dataclass class SettingsEntry: @@ -206,12 +229,11 @@ class SettingsEntry: category: str attr_name: str display_name: str - verbose_name: str = None abbreviated_name: str = None visibility: str = SettingsConstants.VISIBILITY__GENERAL type: str = SettingsConstants.TYPE__ENABLED_DISABLED help_text: str = None - selection_options: List[str] = None + selection_options: list[tuple[str | int], str] = None default_value: Any = None def __post_init__(self): @@ -222,7 +244,7 @@ class SettingsEntry: self.selection_options = SettingsConstants.OPTIONS__ENABLED_DISABLED_PROMPT elif self.type == SettingsConstants.TYPE__ENABLED_DISABLED_PROMPT_REQUIRED: - self.selection_options = [SettingsConstants.ALL_OPTIONS] + self.selection_options = SettingsConstants.ALL_OPTIONS # Account for List[tuple] and tuple formats as default_value if type(self.default_value) == list and type(self.default_value[0]) == tuple: @@ -241,18 +263,12 @@ class SettingsEntry: def get_selection_option_value(self, i: int): + """ Returns the value of the selection option at index `i` """ value = self.selection_options[i] if type(value) == tuple: value = value[0] return value - - def get_selection_option_display_name(self, i: int) -> str: - value = self.selection_options[i] - if type(value) == tuple: - value = value[1] - return value - def get_selection_option_display_name_by_value(self, value) -> str: for option in self.selection_options: @@ -298,9 +314,8 @@ class SettingsEntry: return { "category": self.category, "attr_name": self.attr_name, - "display_name": self.display_name, - "verbose_name": self.verbose_name, "abbreviated_name": self.abbreviated_name, + "display_name": self.display_name, "visibility": self.visibility, "type": self.type, "help_text": self.help_text, @@ -333,6 +348,7 @@ class SettingsDefinition: # TODO: Full babel multilanguage support! Until then, type == HIDDEN SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM, attr_name=SettingsConstants.SETTING__LANGUAGE, + abbreviated_name="lang", display_name="Language", type=SettingsConstants.TYPE__SELECT_1, visibility=SettingsConstants.VISIBILITY__HIDDEN, @@ -342,6 +358,7 @@ class SettingsDefinition: # TODO: Support other bip-39 wordlist languages! Until then, type == HIDDEN SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM, attr_name=SettingsConstants.SETTING__WORDLIST_LANGUAGE, + abbreviated_name="wordlist_lang", display_name="Mnemonic language", type=SettingsConstants.TYPE__SELECT_1, visibility=SettingsConstants.VISIBILITY__HIDDEN, @@ -350,19 +367,27 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM, attr_name=SettingsConstants.SETTING__PERSISTENT_SETTINGS, + abbreviated_name="persistent", display_name="Persistent settings", - help_text="Store Settings on SD card.", + help_text=SettingsConstants.PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT, default_value=SettingsConstants.OPTION__DISABLED), SettingsEntry(category=SettingsConstants.CATEGORY__WALLET, attr_name=SettingsConstants.SETTING__COORDINATORS, + abbreviated_name="coords", display_name="Coordinator software", type=SettingsConstants.TYPE__MULTISELECT, selection_options=SettingsConstants.ALL_COORDINATORS, - default_value=SettingsConstants.ALL_COORDINATORS), + default_value=[ + SettingsConstants.COORDINATOR__BLUE_WALLET, + SettingsConstants.COORDINATOR__NUNCHUK, + SettingsConstants.COORDINATOR__SPARROW, + SettingsConstants.COORDINATOR__SPECTER_DESKTOP, + ]), SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM, attr_name=SettingsConstants.SETTING__BTC_DENOMINATION, + abbreviated_name="denom", display_name="Denomination display", type=SettingsConstants.TYPE__SELECT_1, selection_options=SettingsConstants.ALL_BTC_DENOMINATIONS, @@ -394,6 +419,7 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__SIG_TYPES, + abbreviated_name="sigs", display_name="Sig types", type=SettingsConstants.TYPE__MULTISELECT, visibility=SettingsConstants.VISIBILITY__ADVANCED, @@ -402,11 +428,12 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__SCRIPT_TYPES, + abbreviated_name="scripts", display_name="Script types", type=SettingsConstants.TYPE__MULTISELECT, visibility=SettingsConstants.VISIBILITY__ADVANCED, selection_options=SettingsConstants.ALL_SCRIPT_TYPES, - default_value=[SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT]), + default_value=[SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT, SettingsConstants.TAPROOT]), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__XPUB_DETAILS, @@ -424,32 +451,63 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__CAMERA_ROTATION, + abbreviated_name="camera", display_name="Camera rotation", type=SettingsConstants.TYPE__SELECT_1, visibility=SettingsConstants.VISIBILITY__ADVANCED, selection_options=SettingsConstants.ALL_CAMERA_ROTATIONS, - default_value=SettingsConstants.CAMERA_ROTATION__0), + default_value=SettingsConstants.CAMERA_ROTATION__180), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR, display_name="CompactSeedQR", visibility=SettingsConstants.VISIBILITY__ADVANCED, + default_value=SettingsConstants.OPTION__ENABLED), + + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, + attr_name=SettingsConstants.SETTING__BIP85_CHILD_SEEDS, + abbreviated_name="bip85", + display_name="BIP-85 child seeds", + 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 seeds", + help_text="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", + visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__DISABLED), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__PRIVACY_WARNINGS, + abbreviated_name="priv_warn", display_name="Show privacy warnings", visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__ENABLED), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__DIRE_WARNINGS, + abbreviated_name="dire_warn", display_name="Show dire warnings", visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__ENABLED), + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, + attr_name=SettingsConstants.SETTING__QR_BRIGHTNESS_TIPS, + display_name="Show QR brightness tips", + visibility=SettingsConstants.VISIBILITY__ADVANCED, + default_value=SettingsConstants.OPTION__ENABLED), + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__PARTNER_LOGOS, + abbreviated_name="partners", display_name="Show partner logos", visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__ENABLED), @@ -465,18 +523,19 @@ class SettingsDefinition: # "Hidden" settings with no UI interaction SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM, attr_name=SettingsConstants.SETTING__QR_BRIGHTNESS, + abbreviated_name="qr_brightness", display_name="QR background color", type=SettingsConstants.TYPE__FREE_ENTRY, visibility=SettingsConstants.VISIBILITY__HIDDEN, - default_value=189), + default_value=62), ] @classmethod - def get_settings_entries(cls, visibiilty: str = SettingsConstants.VISIBILITY__GENERAL) -> List[SettingsEntry]: + def get_settings_entries(cls, visibility: str = SettingsConstants.VISIBILITY__GENERAL) -> List[SettingsEntry]: entries = [] for entry in cls.settings_entries: - if entry.visibility == visibiilty: + if entry.visibility == visibility: entries.append(entry) return entries @@ -489,8 +548,10 @@ class SettingsDefinition: @classmethod - def parse_abbreviated_ini(cls, abbreviated_ini: str) -> dict: - raise Exception("Not implemented, maybe not needed") + def get_settings_entry_by_abbreviated_name(cls, abbreviated_name: str) -> SettingsEntry: + for entry in cls.settings_entries: + if abbreviated_name in [entry.abbreviated_name, entry.attr_name]: + return entry @classmethod @@ -520,7 +581,14 @@ class SettingsDefinition: if __name__ == "__main__": import json + import os - output_file = "settings_definition.json" + hostname = os.uname()[1] + + if hostname == "seedsigner-os": + output_file = "/mnt/microsd/settings_definition.json" + else: + output_file = "settings_definition.json" + with open(output_file, 'w') as json_file: json.dump(SettingsDefinition.to_dict(), json_file, indent=4) diff --git a/src/seedsigner/resources/fonts/seedsigner-glyphs.otf b/src/seedsigner/resources/fonts/seedsigner-glyphs.otf deleted file mode 100644 index 63a0008c..00000000 Binary files a/src/seedsigner/resources/fonts/seedsigner-glyphs.otf and /dev/null differ diff --git a/src/seedsigner/resources/fonts/seedsigner-icons.otf b/src/seedsigner/resources/fonts/seedsigner-icons.otf new file mode 100644 index 00000000..ef4292b2 Binary files /dev/null and b/src/seedsigner/resources/fonts/seedsigner-icons.otf differ diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index b5150388..b0a32cd9 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -1,59 +1,59 @@ -import logging -from typing import List - from embit.psbt import PSBT from embit import script from embit.networks import NETWORKS from seedsigner.controller import Controller -from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants -from seedsigner.models.encode_qr import EncodeQR +from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants +from seedsigner.models.encode_qr import UrPsbtQrEncoder from seedsigner.models.psbt_parser import PSBTParser -from seedsigner.models.qr_type import QRType from seedsigner.models.settings import SettingsConstants -from seedsigner.gui.screens import psbt_screens -from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, DireWarningScreen, - LoadingScreenThread, QRDisplayScreen, WarningScreen) - -from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination - -logger = logging.getLogger(__name__) +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 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): # Note: we can't just autoroute to the PSBT Overview because we might have a # multisig where we want to sign with more than one key on this device. if not self.controller.psbt: # Shouldn't be able to get here raise Exception("No PSBT currently loaded") - - seeds = self.controller.storage.seeds - SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + if self.controller.psbt_seed: + if PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.controller.psbt_seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): + # skip the seed prompt if a seed was previous selected and has matching input fingerprint + return Destination(PSBTOverviewView) + + seeds = self.controller.storage.seeds button_data = [] for seed in seeds: button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) if not PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): # Doesn't look like this seed can sign the current PSBT button_str += " (?)" - - if seed.passphrase is not None: - # TODO: Include lock icon on right side of button - pass - button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) - button_data.append(SCAN_SEED) - button_data.append(TYPE_12WORD) - button_data.append(TYPE_24WORD) - selected_menu_num = ButtonListScreen( + button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT)) + + 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, title="Select Signer", is_button_text_centered=False, button_data=button_data - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -66,18 +66,22 @@ class PSBTSelectSeedView(View): # The remaining flows are a sub-flow; resume PSBT flow once the seed is loaded. self.controller.resume_main_flow = Controller.FLOW__PSBT - if button_data[selected_menu_num] == SCAN_SEED: - from seedsigner.views.scan_views import ScanView - return Destination(ScanView) + if button_data[selected_menu_num] == self.SCAN_SEED: + from seedsigner.views.scan_views import ScanSeedQRView + return Destination(ScanSeedQRView) - elif button_data[selected_menu_num] in [TYPE_12WORD, TYPE_24WORD]: + elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: from seedsigner.views.seed_views import SeedMnemonicEntryView - if button_data[selected_menu_num] == TYPE_12WORD: + if button_data[selected_menu_num] == self.TYPE_12WORD: self.controller.storage.init_pending_mnemonic(num_words=12) else: self.controller.storage.init_pending_mnemonic(num_words=24) return Destination(SeedMnemonicEntryView) + elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: + from seedsigner.views.seed_views import SeedElectrumMnemonicStartView + return Destination(SeedElectrumMnemonicStartView) + class PSBTOverviewView(View): @@ -89,8 +93,10 @@ class PSBTOverviewView(View): if not self.controller.psbt_parser or self.controller.psbt_parser.seed != self.controller.psbt_seed: # The PSBTParser takes a while to read the PSBT. Run the loading screen while # we wait. + from seedsigner.gui.screens.screen import LoadingScreenThread self.loading_screen = LoadingScreenThread(text="Parsing PSBT...") self.loading_screen.start() + try: self.controller.psbt_parser = PSBTParser( self.controller.psbt, @@ -125,7 +131,13 @@ class PSBTOverviewView(View): else: num_self_transfer_outputs += 1 - screen = psbt_screens.PSBTOverviewScreen( + # Everything is set. Stop the loading screen + if self.loading_screen: + self.loading_screen.stop() + + # Run the overview screen + selected_menu_num = self.run_screen( + PSBTOverviewScreen, spend_amount=psbt_parser.spend_amount, change_amount=psbt_parser.change_amount, fee_amount=psbt_parser.fee_amount, @@ -133,16 +145,11 @@ class PSBTOverviewView(View): num_self_transfer_outputs=num_self_transfer_outputs, num_change_outputs=num_change_outputs, destination_addresses=psbt_parser.destination_addresses, + has_op_return=psbt_parser.op_return_data is not None, ) - # Everything is set. Stop the loading screen - if self.loading_screen: - self.loading_screen.stop() - - # Run the overview screen - selected_menu_num = screen.display() - if selected_menu_num == RET_CODE__BACK_BUTTON: + self.controller.psbt_seed = None return Destination(BackStackView) # expecting p2sh (legacy multisig) and p2pkh to have no policy set @@ -176,6 +183,8 @@ class PSBTUnsupportedScriptTypeWarningView(View): skip_current_view=True, # Prevent going BACK to WarningViews ) + + class PSBTNoChangeWarningView(View): def run(self): selected_menu_num = WarningScreen( @@ -210,14 +219,15 @@ class PSBTMathView(View): # Should not be able to get here return Destination(MainMenuView) - selected_menu_num = psbt_screens.PSBTMathScreen( + selected_menu_num = self.run_screen( + PSBTMathScreen, input_amount=psbt_parser.input_amount, num_inputs=psbt_parser.num_inputs, spend_amount=psbt_parser.spend_amount, num_recipients=psbt_parser.num_destinations, fee_amount=psbt_parser.fee_amount, change_amount=psbt_parser.change_amount, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -234,7 +244,7 @@ class PSBTAddressDetailsView(View): """ Shows the recipient's address and amount they will receive """ - def __init__(self, address_num, is_change=False): + def __init__(self, address_num): super().__init__() self.address_num = address_num @@ -244,46 +254,51 @@ class PSBTAddressDetailsView(View): if not psbt_parser: # Should not be able to get here - return Destination(MainMenuView) + raise Exception("Routing error") title = "Will Send" if psbt_parser.num_destinations > 1: title += f" (#{self.address_num + 1})" - button_data = [] if self.address_num < psbt_parser.num_destinations - 1: - button_data.append("Next Recipient") + button_data = ["Next Recipient"] else: - button_data.append("Next") + button_data = ["Next"] - selected_menu_num = psbt_screens.PSBTAddressDetailsScreen( + selected_menu_num = self.run_screen( + PSBTAddressDetailsScreen, title=title, button_data=button_data, address=psbt_parser.destination_addresses[self.address_num], amount=psbt_parser.destination_amounts[self.address_num], - ).display() - - if selected_menu_num == 0: - if self.address_num < len(psbt_parser.destination_addresses) - 1: - # Show the next receive addr - return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1, "is_change": False}) - - elif psbt_parser.change_amount > 0: - # Move on to display change - return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0}) - - else: - # There's no change output to verify. Move on to sign the PSBT. - return Destination(PSBTFinalizeView) - + ) + if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) + if self.address_num < len(psbt_parser.destination_addresses) - 1: + # Show the next receive addr + return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1}) + + elif psbt_parser.change_amount > 0: + # Move on to display change + return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0}) + + 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 Verification" + VERIFY_MULTISIG = "Verify Multisig Change" + + def __init__(self, change_address_num): super().__init__() self.change_address_num = change_address_num @@ -323,14 +338,12 @@ class PSBTChangeDetailsView(View): is_change_derivation_path = int(derivation_path.split("/")[-2]) == 1 derivation_path_addr_index = int(derivation_path.split("/")[-1]) - NEXT = "Next" - if is_change_derivation_path: title = "Your Change" - VERIFY_MULTISIG = "Verify Multisig Change" + self.VERIFY_MULTISIG = "Verify Multisig Change" else: title = "Self-Transfer" - VERIFY_MULTISIG = "Verify Multisig Addr" + self.VERIFY_MULTISIG = "Verify Multisig Addr" # if psbt_parser.num_change_outputs > 1: # title += f" (#{self.change_address_num + 1})" @@ -339,11 +352,11 @@ class PSBTChangeDetailsView(View): # if the known-good multisig descriptor is already onboard: if self.controller.multisig_wallet_descriptor: is_change_addr_verified = psbt_parser.verify_multisig_output(self.controller.multisig_wallet_descriptor, change_num=self.change_address_num) - button_data = [NEXT] + button_data = [self.NEXT] else: # Have the Screen offer to load in the multisig descriptor. - button_data = [VERIFY_MULTISIG, NEXT] + button_data = [self.VERIFY_MULTISIG, self.SKIP_VERIFICATION] else: # Single sig @@ -352,6 +365,7 @@ class PSBTChangeDetailsView(View): loading_screen_text = "Verifying Change..." else: loading_screen_text = "Verifying Self-Transfer..." + from seedsigner.gui.screens.screen import LoadingScreenThread loading_screen = LoadingScreenThread(text=loading_screen_text) loading_screen.start() @@ -385,7 +399,7 @@ class PSBTChangeDetailsView(View): if change_data["address"] == calc_address: is_change_addr_verified = True - button_data = [NEXT] + button_data = [self.NEXT] finally: loading_screen.stop() @@ -393,7 +407,8 @@ class PSBTChangeDetailsView(View): if is_change_addr_verified == False and (not psbt_parser.is_multisig or self.controller.multisig_wallet_descriptor is not None): return Destination(PSBTAddressVerificationFailedView, view_args=dict(is_change=is_change_derivation_path, is_multisig=psbt_parser.is_multisig), clear_history=True) - selected_menu_num = psbt_screens.PSBTChangeDetailsScreen( + selected_menu_num = self.run_screen( + PSBTChangeDetailsScreen, title=title, button_data=button_data, address=change_data.get("address"), @@ -404,19 +419,23 @@ class PSBTChangeDetailsView(View): is_change_derivation_path=is_change_derivation_path, derivation_path_addr_index=derivation_path_addr_index, is_change_addr_verified=is_change_addr_verified, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == NEXT: + elif button_data[selected_menu_num] == self.NEXT or button_data[selected_menu_num] == self.SKIP_VERIFICATION: 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) - elif button_data[selected_menu_num] == VERIFY_MULTISIG: + elif button_data[selected_menu_num] == self.VERIFY_MULTISIG: from seedsigner.views.seed_views import LoadMultisigWalletDescriptorView self.controller.resume_main_flow = Controller.FLOW__PSBT return Destination(LoadMultisigWalletDescriptorView) @@ -446,18 +465,46 @@ class PSBTAddressVerificationFailedView(View): show_back_button=False, ).display() - # Clear out the bad PSBT - self.controller.psbt = None - self.controller.psbt_parser = None - self.controller.psbt_seed = None - + # We're done with this PSBT. Route back to MainMenuView which always + # clears all ephemeral data (except in-memory seeds). 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): """ """ + APPROVE_PSBT = "Approve PSBT" + + def run(self): psbt_parser: PSBTParser = self.controller.psbt_parser psbt: PSBT = self.controller.psbt @@ -465,22 +512,20 @@ class PSBTFinalizeView(View): if not psbt_parser: # Should not be able to get here return Destination(MainMenuView) + + selected_menu_num = self.run_screen( + PSBTFinalizeScreen, + button_data=[self.APPROVE_PSBT] + ) - selected_menu_num = psbt_screens.PSBTFinalizeScreen( - button_data=["Approve PSBT"] - ).display() + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) - if selected_menu_num == 0: + else: # Sign PSBT - loading_screen = LoadingScreenThread(text="Signing PSBT...") - loading_screen.start() - - try: - sig_cnt = PSBTParser.sig_count(psbt) - psbt.sign_with(psbt_parser.root) - trimmed_psbt = PSBTParser.trim(psbt) - finally: - loading_screen.stop() + sig_cnt = PSBTParser.sig_count(psbt) + psbt.sign_with(psbt_parser.root) + trimmed_psbt = PSBTParser.trim(psbt) if sig_cnt == PSBTParser.sig_count(trimmed_psbt): # Signing failed / didn't do anything @@ -490,59 +535,27 @@ class PSBTFinalizeView(View): else: self.controller.psbt = trimmed_psbt - - if len(self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)) == 1: - return Destination(PSBTSignedQRDisplayView, view_args={"coordinator": self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[0]}) - else: - return Destination(PSBTSelectCoordinatorView) - - if selected_menu_num == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) - - - -class PSBTSelectCoordinatorView(View): - def run(self): - button_data = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS) - selected_menu_num = psbt_screens.PSBTSelectCoordinatorScreen( - button_data=button_data - ).display() - - if selected_menu_num == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) - - return Destination(PSBTSignedQRDisplayView, view_args={"coordinator": button_data[selected_menu_num]}) + return Destination(PSBTSignedQRDisplayView) class PSBTSignedQRDisplayView(View): - def __init__(self, coordinator: str): - super().__init__() - self.coordinator = coordinator - def run(self): - qr_psbt_type = QRType.PSBT__UR2 - if self.coordinator == SettingsConstants.COORDINATOR__SPECTER_DESKTOP: - qr_psbt_type = QRType.PSBT__SPECTER - - qr_encoder = EncodeQR( + qr_encoder = UrPsbtQrEncoder( psbt=self.controller.psbt, - qr_type=qr_psbt_type, qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY), - wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE), ) - QRDisplayScreen(qr_encoder=qr_encoder).display() - - # We're done with this PSBT. Remove all related data - self.controller.psbt = None - self.controller.psbt_parser = None - self.controller.psbt_seed = None + self.run_screen(QRDisplayScreen, qr_encoder=qr_encoder) + # We're done with this PSBT. Route back to MainMenuView which always + # clears all ephemeral data (except in-memory seeds). return Destination(MainMenuView, clear_history=True) class PSBTSigningErrorView(View): + SELECT_DIFF_SEED = "Select Diff Seed" + def run(self): psbt_parser: PSBTParser = self.controller.psbt_parser if not psbt_parser: @@ -550,15 +563,18 @@ class PSBTSigningErrorView(View): return Destination(MainMenuView) # Just a WarningScreen here; only use DireWarningScreen for true security risks. - selected_menu_num = WarningScreen( + selected_menu_num = self.run_screen( + WarningScreen, title="PSBT Error", - status_icon_name=SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION, + status_icon_name=SeedSignerIconConstants.WARNING, status_headline="Signing Failed", text="Signing with this seed did not add a valid signature.", - button_data=["Select Diff Seed"], - ).display() + button_data=[self.SELECT_DIFF_SEED] + ) if selected_menu_num == 0: + # clear seed selected for psbt signing since it did not add a valid signature + self.controller.psbt_seed = None return Destination(PSBTSelectSeedView, clear_history=True) if selected_menu_num == RET_CODE__BACK_BUTTON: diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index 1376cf1d..1b923efa 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -1,30 +1,76 @@ -import json +import logging import re from embit.descriptor import Descriptor from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON -from seedsigner.models import DecodeQR, Seed +from seedsigner.models.decode_qr import DecodeQR +from seedsigner.models.seed import Seed from seedsigner.models.settings import SettingsConstants +from seedsigner.views.settings_views import SettingsIngestSettingsQRView +from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, OptionDisabledView, View, Destination -from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination - +logger = logging.getLogger(__name__) class ScanView(View): + """ + The catch-all generic scanning View that will accept any of our supported QR + formats and will route to the most sensible next step. + + Can also be used as a base class for more specific scanning flows with + dedicated errors when an unexpected QR type is scanned (e.g. Scan PSBT was + selected but a SeedQR was scanned). + """ + instructions_text = "Scan a QR code" + invalid_qr_type_message = "QRCode not recognized or not yet supported." + + + def __init__(self): + super().__init__() + # Define the decoder here to make it available to child classes' is_valid_qr_type + # checks and so we can inject data into it in the test suite's `before_run()`. + self.wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) + self.decoder: DecodeQR = DecodeQR(wordlist_language_code=self.wordlist_language_code) + + + @property + def is_valid_qr_type(self): + return True + + def run(self): from seedsigner.gui.screens.scan_screens import ScanScreen - # Run the live preview and QR code capture process - # TODO: Does this belong in its own BaseThread? - wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) - self.decoder = DecodeQR(wordlist_language_code=wordlist_language_code) - screen = ScanScreen(decoder=self.decoder) - screen.display() + # Start the live preview and background QR reading + self.run_screen( + ScanScreen, + instructions_text=self.instructions_text, + decoder=self.decoder + ) + # A long scan might have exceeded the screensaver timeout; ensure screensaver + # doesn't immediately engage when we leave here. + self.controller.reset_screensaver_timeout() + + # Handle the results if self.decoder.is_complete: + if not self.is_valid_qr_type: + # We recognized the QR type but it was not the type expected for the + # current flow. + # Report QR types in more human-readable text (e.g. QRType + # `seed__compactseedqr` as "seed: compactseedqr"). + return Destination(ErrorView, view_args=dict( + title="Error", + status_headline="Wrong QR Type", + text=self.invalid_qr_type_message + f""", received "{self.decoder.qr_type.replace("__", ": ").replace("_", " ")}\" format""", + button_text="Back", + next_destination=Destination(BackStackView, skip_current_view=True), + )) + if self.decoder.is_seed: seed_mnemonic = self.decoder.get_seed_phrase() + if not seed_mnemonic: # seed is not valid, Exit if not valid with message raise Exception("Not yet implemented!") @@ -33,7 +79,7 @@ class ScanView(View): # pending (might set a passphrase, SeedXOR, etc) until finalized. from .seed_views import SeedFinalizeView self.controller.storage.set_pending_seed( - Seed(mnemonic=seed_mnemonic, wordlist_language_code=wordlist_language_code) + Seed(mnemonic=seed_mnemonic, wordlist_language_code=self.wordlist_language_code) ) if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__REQUIRED: from seedsigner.views.seed_views import SeedAddPassphraseView @@ -46,16 +92,11 @@ class ScanView(View): psbt = self.decoder.get_psbt() self.controller.psbt = psbt self.controller.psbt_parser = None - return Destination(PSBTSelectSeedView) + return Destination(PSBTSelectSeedView, skip_current_view=True) elif self.decoder.is_settings: - from seedsigner.models.settings import Settings - settings = self.decoder.get_settings_data() - Settings.get_instance().update(new_settings=settings) - - print(json.dumps(Settings.get_instance()._data, indent=4)) - - return Destination(SettingsUpdatedView, {"config_name": self.decoder.get_settings_config_name()}) + data = self.decoder.get_settings_data() + return Destination(SettingsIngestSettingsQRView, view_args=dict(data=data)) elif self.decoder.is_wallet_descriptor: from seedsigner.views.seed_views import MultisigWalletDescriptorView @@ -72,18 +113,18 @@ class ScanView(View): p = re.compile(r'(\[[0-9,a-f,A-F]+?\/[0-9,\/,h,\']+?\][a-z,A-Z,0-9]*?)([\,,\)])') descriptor_str = p.sub(r'\1/{0,1}/*\2', descriptor_str) except Exception as e: - print(repr(e)) + logger.info(repr(e), exc_info=True) descriptor_str = orig_descriptor_str descriptor = Descriptor.from_string(descriptor_str) if not descriptor.is_basic_multisig: # TODO: Handle single-sig descriptors? - print(f"Received single sig descriptor: {descriptor}") + logger.info(f"Received single sig descriptor: {descriptor}") return Destination(NotYetImplementedView) self.controller.multisig_wallet_descriptor = descriptor - return Destination(MultisigWalletDescriptorView) + return Destination(MultisigWalletDescriptorView, skip_current_view=True) elif self.decoder.is_address: from seedsigner.views.seed_views import AddressVerificationStartView @@ -92,6 +133,7 @@ class ScanView(View): return Destination( AddressVerificationStartView, + skip_current_view=True, view_args={ "address": address, "script_type": script_type, @@ -99,28 +141,71 @@ class ScanView(View): } ) + elif self.decoder.is_sign_message: + from seedsigner.views.seed_views import SeedSignMessageStartView + qr_data = self.decoder.get_qr_data() + + return Destination( + SeedSignMessageStartView, + view_args=dict( + derivation_path=qr_data["derivation_path"], + message=qr_data["message"], + ) + ) + else: return Destination(NotYetImplementedView) + elif self.decoder.is_invalid: + # 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(MainMenuView) -class SettingsUpdatedView(View): - def __init__(self, config_name: str): - super().__init__() +class ScanPSBTView(ScanView): + instructions_text = "Scan PSBT" + invalid_qr_type_message = "Expected a PSBT" - self.config_name = config_name - + @property + def is_valid_qr_type(self): + return self.decoder.is_psbt - def run(self): - from seedsigner.gui.screens.scan_screens import SettingsUpdatedScreen - screen = SettingsUpdatedScreen(config_name=self.config_name) - selected_menu_num = screen.display() - if selected_menu_num == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) - # Only one exit point - return Destination(MainMenuView) +class ScanSeedQRView(ScanView): + instructions_text = "Scan SeedQR" + invalid_qr_type_message = f"Expected a SeedQR" + @property + def is_valid_qr_type(self): + return self.decoder.is_seed + + + +class ScanWalletDescriptorView(ScanView): + instructions_text = "Scan descriptor" + invalid_qr_type_message = "Expected a wallet descriptor QR" + + @property + def is_valid_qr_type(self): + return self.decoder.is_wallet_descriptor + + + +class ScanAddressView(ScanView): + instructions_text = "Scan address QR" + invalid_qr_type_message = "Expected an address QR" + + @property + def is_valid_qr_type(self): + return self.decoder.is_address diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py index 29fb8da4..84476da4 100644 --- a/src/seedsigner/views/screensaver.py +++ b/src/seedsigner/views/screensaver.py @@ -1,3 +1,4 @@ +import logging import os import random import time @@ -9,6 +10,7 @@ from seedsigner.gui.screens.screen import BaseScreen from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants +logger = logging.getLogger(__name__) # TODO: This early code is now outdated vis-a-vis Screen vs View distinctions @@ -89,7 +91,6 @@ class OpeningSplashScreen(LogoScreen): - class ScreensaverScreen(LogoScreen): def __init__(self, buttons): super().__init__() @@ -141,9 +142,9 @@ class ScreensaverScreen(LogoScreen): # never gives up the lock until it returns. with self.renderer.lock: try: - while True: - if self.buttons.has_any_input(): - return self.stop() + while self._is_running: + if self.buttons.has_any_input() or self.buttons.override_ind: + break # Must crop the image to the exact display size crop = self.image.crop(( @@ -176,20 +177,23 @@ class ScreensaverScreen(LogoScreen): self.increment_y = self.rand_increment() if self.increment_y > 0.0: self.increment_y *= -1.0 + except KeyboardInterrupt as e: # Exit triggered; close gracefully - print("Shutting down Screensaver") - self.stop() + logger.info("Shutting down Screensaver") # Have to let the interrupt bubble up to exit the main app raise e + finally: + self._is_running = False + + # Restore the original screen + self.renderer.show_image(self.last_screen) + def stop(self): - # Restore the original screen - self.renderer.show_image(self.last_screen) - self._is_running = False diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 13a26f81..9483c111 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -1,3 +1,4 @@ +import logging import embit import random import time @@ -9,34 +10,34 @@ from embit.networks import NETWORKS from typing import List from seedsigner.controller import Controller -from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants +from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants +from seedsigner.helpers import embit_utils from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, seed_screens) -from seedsigner.gui.screens.screen import LargeIconStatusScreen, LoadingScreenThread, QRDisplayScreen +from seedsigner.gui.screens.screen import LargeIconStatusScreen, QRDisplayScreen +from seedsigner.helpers import embit_utils from seedsigner.models.decode_qr import DecodeQR -from seedsigner.models.encode_qr import EncodeQR +from seedsigner.models.encode_qr import CompactSeedQrEncoder, GenericStaticQrEncoder, SeedQrEncoder, SpecterXPubQrEncoder, StaticXpubQrEncoder, UrXpubQrEncoder from seedsigner.models.psbt_parser import PSBTParser from seedsigner.models.qr_type import QRType from seedsigner.models.seed import InvalidSeedException, Seed -from seedsigner.models.settings import SettingsConstants +from seedsigner.models.settings import Settings, SettingsConstants from seedsigner.models.settings_definition import SettingsDefinition from seedsigner.models.threads import BaseThread, ThreadsafeCounter -from seedsigner.views.psbt_views import PSBTChangeDetailsView -from seedsigner.views.scan_views import ScanView - -from .view import NotYetImplementedView, View, Destination, BackStackView, MainMenuView - +from seedsigner.views.view import NotYetImplementedView, OptionDisabledView, View, Destination, BackStackView, MainMenuView +logger = logging.getLogger(__name__) class SeedsMenuView(View): + LOAD = "Load a seed" + def __init__(self): super().__init__() self.seeds = [] for seed in self.controller.storage.seeds: self.seeds.append({ - "fingerprint": seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)), - "has_passphrase": seed.passphrase is not None + "fingerprint": seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) }) @@ -47,14 +48,15 @@ class SeedsMenuView(View): button_data = [] for seed in self.seeds: - button_data.append((seed["fingerprint"], SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + button_data.append((seed["fingerprint"], SeedSignerIconConstants.FINGERPRINT)) button_data.append("Load a seed") - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="In-Memory Seeds", is_button_text_centered=False, button_data=button_data - ).display() + ) if len(self.seeds) > 0 and selected_menu_num < len(self.seeds): return Destination(SeedOptionsView, view_args={"seed_num": selected_menu_num}) @@ -67,44 +69,149 @@ class SeedsMenuView(View): -"""**************************************************************************** - Loading seeds, passphrases, etc -****************************************************************************""" -class LoadSeedView(View): - def run(self): - SEED_QR = (" Scan a SeedQR", FontAwesomeIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - CREATE = (" Create a seed", FontAwesomeIconConstants.PLUS) - button_data=[ - SEED_QR, - TYPE_12WORD, - TYPE_24WORD, - CREATE, - ] +class SeedSelectSeedView(View): + """ + Reusable seed selection UI. Prompts the user to select amongst the already-loaded + seeds OR to load a seed. - selected_menu_num = ButtonListScreen( - title="Load A Seed", + * `flow`: indicates which user flow is in progress during seed selection (e.g. + verify single sig addr or sign message). + """ + SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE) + TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_ELECTRUM = ("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) + + + def __init__(self, flow: str = Controller.FLOW__VERIFY_SINGLESIG_ADDR): + super().__init__() + self.flow = flow + + + def run(self): + seeds = self.controller.storage.seeds + + if self.flow == Controller.FLOW__VERIFY_SINGLESIG_ADDR: + title = "Verify Address" + if not seeds: + text = "Load the seed to verify" + else: + text = "Select seed to verify" + + elif self.flow == Controller.FLOW__SIGN_MESSAGE: + title = "Sign Message" + if not seeds: + text = "Load the seed to sign with" + else: + text = "Select seed to sign with" + + else: + raise Exception(f"Unsupported `flow` specified: {self.flow}") + + button_data = [] + for seed in seeds: + button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) + + if seed.passphrase is not None: + # TODO: Include lock icon on right side of button + pass + button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT, "blue")) + + button_data.append(self.SCAN_SEED) + button_data.append(self.TYPE_12WORD) + button_data.append(self.TYPE_24WORD) + + 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, + title=title, + text=text, is_button_text_centered=False, - button_data=button_data - ).display() + button_data=button_data, + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if button_data[selected_menu_num] == SEED_QR: - from .scan_views import ScanView + if len(seeds) > 0 and selected_menu_num < len(seeds): + # User selected one of the n seeds + view_args = dict(seed_num=selected_menu_num) + if self.flow == Controller.FLOW__VERIFY_SINGLESIG_ADDR: + return Destination(SeedAddressVerificationView, view_args=view_args) + + elif self.flow == Controller.FLOW__SIGN_MESSAGE: + self.controller.sign_message_data["seed_num"] = selected_menu_num + return Destination(SeedSignMessageConfirmMessageView) + + self.controller.resume_main_flow = self.flow + + if button_data[selected_menu_num] == self.SCAN_SEED: + from seedsigner.views.scan_views import ScanView return Destination(ScanView) + + elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: + from seedsigner.views.seed_views import SeedMnemonicEntryView + if button_data[selected_menu_num] == self.TYPE_12WORD: + self.controller.storage.init_pending_mnemonic(num_words=12) + else: + self.controller.storage.init_pending_mnemonic(num_words=24) + return Destination(SeedMnemonicEntryView) + + elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: + return Destination(SeedElectrumMnemonicStartView) + + + +"""**************************************************************************** + Loading seeds, passphrases, etc +****************************************************************************""" +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): + button_data = [ + self.SEED_QR, + self.TYPE_12WORD, + self.TYPE_24WORD, + ] + + if self.settings.get_value(SettingsConstants.SETTING__ELECTRUM_SEEDS) == SettingsConstants.OPTION__ENABLED: + button_data.append(self.TYPE_ELECTRUM) - elif button_data[selected_menu_num] == TYPE_12WORD: + button_data.append(self.CREATE) + + selected_menu_num = self.run_screen( + ButtonListScreen, + title="Load A Seed", + is_button_text_centered=False, + button_data=button_data + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + if button_data[selected_menu_num] == self.SEED_QR: + from .scan_views import ScanSeedQRView + return Destination(ScanSeedQRView) + + elif button_data[selected_menu_num] == self.TYPE_12WORD: self.controller.storage.init_pending_mnemonic(num_words=12) return Destination(SeedMnemonicEntryView) - elif button_data[selected_menu_num] == TYPE_24WORD: + elif button_data[selected_menu_num] == self.TYPE_24WORD: self.controller.storage.init_pending_mnemonic(num_words=24) return Destination(SeedMnemonicEntryView) - elif button_data[selected_menu_num] == CREATE: + elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: + return Destination(SeedElectrumMnemonicStartView) + + elif button_data[selected_menu_num] == self.CREATE: from .tools_views import ToolsMenuView return Destination(ToolsMenuView) @@ -119,22 +226,16 @@ class SeedMnemonicEntryView(View): def run(self): - ret = seed_screens.SeedMnemonicEntryScreen( + ret = self.run_screen( + seed_screens.SeedMnemonicEntryScreen, title=f"Seed Word #{self.cur_word_index + 1}", # Human-readable 1-indexing! initial_letters=list(self.cur_word) if self.cur_word else ["a"], wordlist=Seed.get_wordlist(wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)), - ).display() + ) if ret == RET_CODE__BACK_BUTTON: if self.cur_word_index > 0: return Destination(BackStackView) - # return Destination( - # SeedMnemonicEntryView, - # view_args={ - # "cur_word_index": self.cur_word_index - 1, - # "is_calc_final_word": self.is_calc_final_word - # } - # ) else: self.controller.storage.discard_pending_mnemonic() return Destination(MainMenuView) @@ -174,34 +275,37 @@ class SeedMnemonicEntryView(View): class SeedMnemonicInvalidView(View): + EDIT = "Review & Edit" + DISCARD = ("Discard", None, None, "red") + def __init__(self): super().__init__() self.mnemonic: List[str] = self.controller.storage.pending_mnemonic def run(self): - EDIT = "Review & Edit" - DISCARD = ("Discard", None, None, "red") - button_data = [EDIT, DISCARD] - - selected_menu_num = WarningScreen( + button_data = [self.EDIT, self.DISCARD] + selected_menu_num = self.run_screen( + WarningScreen, title="Invalid Mnemonic!", status_headline=None, text=f"Checksum failure; not a valid seed phrase.", show_back_button=False, button_data=button_data, - ).display() + ) - if button_data[selected_menu_num] == EDIT: + if button_data[selected_menu_num] == self.EDIT: return Destination(SeedMnemonicEntryView, view_args={"cur_word_index": 0}) - elif button_data[selected_menu_num] == DISCARD: + elif button_data[selected_menu_num] == self.DISCARD: self.controller.storage.discard_pending_mnemonic() return Destination(MainMenuView) class SeedFinalizeView(View): + FINALIZE = "Done" + def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() @@ -209,25 +313,22 @@ class SeedFinalizeView(View): def run(self): - FINALIZE = "Done" - PASSPHRASE = ("Add Passphrase", FontAwesomeIconConstants.LOCK) - button_data = [] - - button_data.append(FINALIZE) - + button_data = [self.FINALIZE] + passphrase_button = self.seed.passphrase_label if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: - button_data.append(PASSPHRASE) + button_data.append(passphrase_button) - selected_menu_num = seed_screens.SeedFinalizeScreen( + selected_menu_num = self.run_screen( + seed_screens.SeedFinalizeScreen, fingerprint=self.fingerprint, button_data=button_data, - ).display() + ) - if button_data[selected_menu_num] == FINALIZE: + if button_data[selected_menu_num] == self.FINALIZE: 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] == PASSPHRASE: + elif button_data[selected_menu_num] == passphrase_button: return Destination(SeedAddPassphraseView) @@ -239,31 +340,69 @@ class SeedAddPassphraseView(View): def run(self): - ret = seed_screens.SeedAddPassphraseScreen(passphrase=self.seed.passphrase).display() + passphrase_title=self.seed.passphrase_label + ret_dict = self.run_screen(seed_screens.SeedAddPassphraseScreen, passphrase=self.seed.passphrase, title=passphrase_title) - if ret == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) + # The new passphrase will be the return value; it might be empty. + self.seed.set_passphrase(ret_dict["passphrase"]) + + if "is_back_button" in ret_dict: + if len(self.seed.passphrase) > 0: + return Destination(SeedAddPassphraseExitDialogView) + else: + return Destination(BackStackView) + + elif len(self.seed.passphrase) > 0: + return Destination(SeedReviewPassphraseView) - # The new passphrase will be the return value - self.seed.set_passphrase(ret) - return Destination(SeedReviewPassphraseView) + else: + return Destination(SeedFinalizeView) -class SeedReviewPassphraseView(View): - """ - Display the completed passphrase back to the user. - """ +class SeedAddPassphraseExitDialogView(View): + EDIT = "Edit passphrase" + DISCARD = ("Discard passphrase", None, None, "red") + def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() def run(self): - EDIT = "Edit passphrase" - DONE = "Done" - button_data = [EDIT, DONE] + button_data = [self.EDIT, self.DISCARD] + + selected_menu_num = self.run_screen( + WarningScreen, + title="Discard passphrase?", + status_headline=None, + text=f"Your current passphrase entry will be erased", + show_back_button=False, + button_data=button_data, + ) + if button_data[selected_menu_num] == self.EDIT: + return Destination(SeedAddPassphraseView) + + elif button_data[selected_menu_num] == self.DISCARD: + self.seed.set_passphrase("") + return Destination(SeedFinalizeView) + + + +class SeedReviewPassphraseView(View): + """ + Display the completed passphrase back to the user. + """ + EDIT = "Edit passphrase" + DONE = "Done" + + def __init__(self): + super().__init__() + self.seed = self.controller.storage.get_pending_seed() + + + def run(self): # Get the before/after fingerprints network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) passphrase = self.seed.passphrase @@ -272,26 +411,32 @@ class SeedReviewPassphraseView(View): fingerprint_without = self.seed.get_fingerprint(network=network) self.seed.set_passphrase(passphrase) - # Because we have ane explicit "Edit" button, we disable "BACK" to keep the + button_data = [self.EDIT, self.DONE] + + # Because we have an explicit "Edit" button, we disable "BACK" to keep the # routing options sane. - selected_menu_num = seed_screens.SeedReviewPassphraseScreen( + selected_menu_num = self.run_screen( + seed_screens.SeedReviewPassphraseScreen, fingerprint_without=fingerprint_without, fingerprint_with=fingerprint_with, passphrase=self.seed.passphrase, button_data=button_data, show_back_button=False, - ).display() + ) - if button_data[selected_menu_num] == EDIT: + if button_data[selected_menu_num] == self.EDIT: return Destination(SeedAddPassphraseView) - elif button_data[selected_menu_num] == DONE: + elif button_data[selected_menu_num] == self.DONE: seed_num = self.controller.storage.finalize_pending_seed() return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True) - + class SeedDiscardView(View): + KEEP = "Keep Seed" + DISCARD = ("Discard", None, None, "red") + def __init__(self, seed_num: int = None): super().__init__() self.seed_num = seed_num @@ -302,27 +447,26 @@ class SeedDiscardView(View): def run(self): - KEEP = "Keep Seed" - DISCARD = ("Discard", None, None, "red") - button_data = [KEEP, DISCARD] + button_data = [self.KEEP, self.DISCARD] fingerprint = self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - selected_menu_num = WarningScreen( + selected_menu_num = self.run_screen( + WarningScreen, title="Discard Seed?", status_headline=None, text=f"Wipe seed {fingerprint} from the device?", show_back_button=False, button_data=button_data, - ).display() + ) - if button_data[selected_menu_num] == KEEP: + if button_data[selected_menu_num] == self.KEEP: # Use skip_current_view=True to prevent BACK from landing on this warning screen if self.seed_num is not None: return Destination(SeedOptionsView, view_args={"seed_num": self.seed_num}, skip_current_view=True) else: return Destination(SeedFinalizeView, skip_current_view=True) - elif button_data[selected_menu_num] == DISCARD: + elif button_data[selected_menu_num] == self.DISCARD: if self.seed_num is not None: self.controller.discard_seed(self.seed_num) else: @@ -331,10 +475,41 @@ class SeedDiscardView(View): +class SeedElectrumMnemonicStartView(View): + """ + Currently just a warning display before entering an Electrum seed. + + Could be expanded with a follow-up View to specify Electrum seed type. + """ + def run(self): + self.run_screen( + WarningScreen, + title="Electrum warning", + status_headline=None, + text=f"Some features are disabled for Electrum seeds.", + show_back_button=False, + ) + + self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) + + return Destination(SeedMnemonicEntryView) + + + """**************************************************************************** Views for actions on individual seeds: ****************************************************************************""" class SeedOptionsView(View): + SCAN_PSBT = ("Scan PSBT", SeedSignerIconConstants.QRCODE) + VERIFY_ADDRESS = "Verify Addr" + EXPORT_XPUB = "Export Xpub" + EXPLORER = "Address Explorer" + SIGN_MESSAGE = "Sign Message" + BACKUP = ("Backup Seed", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT) + BIP85_CHILD_SEED = "BIP-85 Child Seed" + DISCARD = ("Discard Seed", None, None, "red") + + def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num @@ -344,79 +519,100 @@ class SeedOptionsView(View): def run(self): from seedsigner.views.psbt_views import PSBTOverviewView - SCAN_PSBT = ("Scan PSBT", FontAwesomeIconConstants.QRCODE) - REVIEW_PSBT = "Review PSBT" - VERIFY_ADDRESS = "Verify Addr" - EXPORT_XPUB = "Export Xpub" - BACKUP = ("Backup Seed", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT) - DISCARD = ("Discard Seed", None, None, "red") - - button_data = [] - if self.controller.unverified_address: if self.controller.resume_main_flow == Controller.FLOW__VERIFY_SINGLESIG_ADDR: # Jump straight back into the single sig addr verification flow self.controller.resume_main_flow = None return Destination(SeedAddressVerificationView, view_args=dict(seed_num=self.seed_num), skip_current_view=True) - addr = self.controller.unverified_address["address"][:7] - VERIFY_ADDRESS += f" {addr}" - button_data.append(VERIFY_ADDRESS) - + if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: + # Jump straight back into the address explorer script type selection flow + # But don't cancel the `resume_main_flow` as we'll still need that after + # derivation path is specified. + return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG), skip_current_view=True) + + elif self.controller.resume_main_flow == Controller.FLOW__SIGN_MESSAGE: + self.controller.sign_message_data["seed_num"] = self.seed_num + return Destination(SeedSignMessageConfirmMessageView, skip_current_view=True) + if self.controller.psbt: if PSBTParser.has_matching_input_fingerprint(self.controller.psbt, self.seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): if self.controller.resume_main_flow and self.controller.resume_main_flow == Controller.FLOW__PSBT: - # Re-route us directly back to the start of the PSBT flow + # Re-route us directly back to the start of the PSBT flow self.controller.resume_main_flow = None self.controller.psbt_seed = self.seed return Destination(PSBTOverviewView, skip_current_view=True) - else: - # This seed does not seem to be a signer for this PSBT - # TODO: How sure are we? Should disable this entirely if we're 100% sure? - REVIEW_PSBT += " (?)" - button_data.append(REVIEW_PSBT) - else: - button_data.append(SCAN_PSBT) + + button_data = [] + + if self.controller.unverified_address: + addr = self.controller.unverified_address["address"][:7] + self.VERIFY_ADDRESS += f" {addr}" + button_data.append(self.VERIFY_ADDRESS) + + button_data.append(self.SCAN_PSBT) if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED: - button_data.append(EXPORT_XPUB) + button_data.append(self.EXPORT_XPUB) - button_data.append(BACKUP) - button_data.append(DISCARD) + button_data.append(self.EXPLORER) + button_data.append(self.BACKUP) - selected_menu_num = seed_screens.SeedOptionsScreen( + 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 and self.seed.bip85_supported: + button_data.append(self.BIP85_CHILD_SEED) + + button_data.append(self.DISCARD) + + selected_menu_num = self.run_screen( + seed_screens.SeedOptionsScreen, button_data=button_data, fingerprint=self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)), - has_passphrase=self.seed.passphrase is not None - ).display() + has_passphrase=self.seed.passphrase is not None, + ) if selected_menu_num == RET_CODE__BACK_BUTTON: # Force BACK to always return to the Main Menu return Destination(MainMenuView) - if button_data[selected_menu_num] == REVIEW_PSBT: + if button_data[selected_menu_num] == self.SCAN_PSBT: + from seedsigner.views.scan_views import ScanPSBTView self.controller.psbt_seed = self.controller.get_seed(self.seed_num) - return Destination(PSBTOverviewView) + return Destination(ScanPSBTView) - if button_data[selected_menu_num] == SCAN_PSBT: + elif button_data[selected_menu_num] == self.VERIFY_ADDRESS: + return Destination(SeedAddressVerificationView, view_args=dict(seed_num=self.seed_num)) + + elif button_data[selected_menu_num] == self.EXPORT_XPUB: + return Destination(SeedExportXpubSigTypeView, view_args=dict(seed_num=self.seed_num)) + + elif button_data[selected_menu_num] == self.EXPLORER: + self.controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER + return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG)) + + elif button_data[selected_menu_num] == self.SIGN_MESSAGE: from seedsigner.views.scan_views import ScanView + self.controller.sign_message_data = dict(seed_num=self.seed_num) + self.controller.resume_main_flow = Controller.FLOW__SIGN_MESSAGE return Destination(ScanView) - elif button_data[selected_menu_num] == VERIFY_ADDRESS: - return Destination(SeedAddressVerificationView, view_args={"seed_num": self.seed_num}) + elif button_data[selected_menu_num] == self.BACKUP: + return Destination(SeedBackupView, view_args=dict(seed_num=self.seed_num)) - elif button_data[selected_menu_num] == EXPORT_XPUB: - return Destination(SeedExportXpubSigTypeView, view_args={"seed_num": self.seed_num}) + elif button_data[selected_menu_num] == self.BIP85_CHILD_SEED: + return Destination(SeedBIP85ApplicationModeView, view_args={"seed_num": self.seed_num}) - elif button_data[selected_menu_num] == BACKUP: - return Destination(SeedBackupView, view_args={"seed_num": self.seed_num}) - - elif button_data[selected_menu_num] == DISCARD: - return Destination(SeedDiscardView, view_args={"seed_num": self.seed_num}) + elif button_data[selected_menu_num] == self.DISCARD: + return Destination(SeedDiscardView, view_args=dict(seed_num=self.seed_num)) class SeedBackupView(View): + VIEW_WORDS = "View Seed Words" + EXPORT_SEEDQR = "Export as SeedQR" + def __init__(self, seed_num): super().__init__() self.seed_num = seed_num @@ -424,23 +620,25 @@ class SeedBackupView(View): def run(self): - VIEW_WORDS = "View Seed Words" - EXPORT_SEEDQR = "Export as SeedQR" - button_data = [VIEW_WORDS, EXPORT_SEEDQR] + button_data = [self.VIEW_WORDS] - selected_menu_num = ButtonListScreen( + if self.seed.seedqr_supported: + button_data.append(self.EXPORT_SEEDQR) + + selected_menu_num = self.run_screen( + ButtonListScreen, title="Backup Seed", button_data=button_data, is_bottom_list=True, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == VIEW_WORDS: + elif button_data[selected_menu_num] == self.VIEW_WORDS: return Destination(SeedWordsWarningView, view_args={"seed_num": self.seed_num}) - elif button_data[selected_menu_num] == EXPORT_SEEDQR: + elif button_data[selected_menu_num] == self.EXPORT_SEEDQR: return Destination(SeedTranscribeSeedQRFormatView, view_args={"seed_num": self.seed_num}) @@ -449,6 +647,9 @@ class SeedBackupView(View): Export Xpub flow ****************************************************************************""" class SeedExportXpubSigTypeView(View): + SINGLE_SIG = "Single Sig" + MULTISIG = "Multisig" + def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num @@ -459,22 +660,21 @@ class SeedExportXpubSigTypeView(View): # Nothing to select; skip this screen return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": self.settings.get_value(SettingsConstants.SETTING__SIG_TYPES)[0]}, skip_current_view=True) - SINGLE_SIG = "Single Sig" - MULTISIG = "Multisig" - button_data=[SINGLE_SIG, MULTISIG] + button_data=[self.SINGLE_SIG, self.MULTISIG] - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="Export Xpub", button_data=button_data - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if button_data[selected_menu_num] == SINGLE_SIG: + if button_data[selected_menu_num] == self.SINGLE_SIG: return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.SINGLE_SIG}) - elif button_data[selected_menu_num] == MULTISIG: + elif button_data[selected_menu_num] == self.MULTISIG: return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.MULTISIG}) @@ -487,23 +687,51 @@ 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: + + script_types = self.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES) + + seed = self.controller.storage.seeds[self.seed_num] + if seed.script_override: + # This seed only allows one script type + # TODO: Does it matter if the Settings don't have the override script type + # enabled? + script_types = [seed.script_override] + + if len(script_types) == 1: # Nothing to select; skip this screen - args["script_type"] = self.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES)[0] - return Destination(SeedExportXpubCoordinatorView, view_args=args, skip_current_view=True) + args["script_type"] = script_types[0] + + if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: + del args["sig_type"] + return Destination(ToolsAddressExplorerAddressTypeView, view_args=args, skip_current_view=True) + else: + return Destination(SeedExportXpubCoordinatorView, view_args=args, skip_current_view=True) + + title = "Export Xpub" + if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: + title = "Address Explorer" button_data = [] for script_type in self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES): button_data.append(script_type) - selected_menu_num = ButtonListScreen( - title="Export Xpub", + + selected_menu_num = self.run_screen( + ButtonListScreen, + title=title, is_button_text_centered=False, button_data=button_data, - ).display() + is_bottom_list=True, + ) - if selected_menu_num < len(button_data): + if selected_menu_num == RET_CODE__BACK_BUTTON: + # If previous view is SeedOptionsView then that should be where resume_main_flow started (otherwise it would have been skipped). + if len(self.controller.back_stack) >= 2 and self.controller.back_stack[-2].View_cls == SeedOptionsView: + self.controller.resume_main_flow = None + return Destination(BackStackView) + else: script_types_settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES) selected_display_name = button_data[selected_menu_num] args["script_type"] = script_types_settings_entry.get_selection_option_value_by_display_name(selected_display_name) @@ -511,10 +739,11 @@ class SeedExportXpubScriptTypeView(View): if args["script_type"] == SettingsConstants.CUSTOM_DERIVATION: return Destination(SeedExportXpubCustomDerivationView, view_args=args) - return Destination(SeedExportXpubCoordinatorView, view_args=args) - - elif selected_menu_num == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) + if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: + del args["sig_type"] + return Destination(ToolsAddressExplorerAddressTypeView, view_args=args) + else: + return Destination(SeedExportXpubCoordinatorView, view_args=args) @@ -526,17 +755,23 @@ class SeedExportXpubCustomDerivationView(View): self.script_type = script_type self.custom_derivation_path = "m/" + def run(self): - ret = seed_screens.SeedExportXpubCustomDerivationScreen( + ret = self.run_screen( + seed_screens.SeedExportXpubCustomDerivationScreen, initial_value=self.custom_derivation_path, - ).display() + ) if ret == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - # ret should be the custom derivation path; store it in Settings + # ret will be the custom derivation path custom_derivation = ret + if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: + from .tools_views import ToolsAddressExplorerAddressTypeView + return Destination(ToolsAddressExplorerAddressTypeView, view_args=dict(seed_num=self.seed_num, script_type=self.script_type, custom_derivation=custom_derivation)) + return Destination( SeedExportXpubCoordinatorView, view_args={ @@ -570,19 +805,25 @@ class SeedExportXpubCoordinatorView(View): args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[0] return Destination(SeedExportXpubWarningView, view_args=args, skip_current_view=True) - selected_menu_num = ButtonListScreen( + button_data = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS) + + selected_menu_num = self.run_screen( + ButtonListScreen, title="Export Xpub", is_button_text_centered=False, - button_data=self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS), - ).display() + button_data=button_data, + ) - if selected_menu_num < len(self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)): - args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[selected_menu_num] - return Destination(SeedExportXpubWarningView, view_args=args) - - elif selected_menu_num == RET_CODE__BACK_BUTTON: + if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) + coordinators_settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS) + selected_display_name = button_data[selected_menu_num] + args["coordinator"] = coordinators_settings_entry.get_selection_option_value_by_display_name(selected_display_name) + + return Destination(SeedExportXpubWarningView, view_args=args) + + class SeedExportXpubWarningView(View): @@ -612,10 +853,11 @@ class SeedExportXpubWarningView(View): # Skip the WarningView entirely return destination - selected_menu_num = WarningScreen( + selected_menu_num = self.run_screen( + WarningScreen, status_headline="Privacy Leak!", text="""Xpub can be used to view all future transactions.""", - ).display() + ) if selected_menu_num == 0: # User clicked "I Understand" @@ -643,65 +885,63 @@ class SeedExportXpubDetailsView(View): def run(self): - # The calc_derivation takes a few moments. Run the loading screen while we wait. - self.loading_screen = LoadingScreenThread(text="Generating xpub...") - self.loading_screen.start() + 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), + wallet_type=self.sig_type, + script_type=self.script_type + ) - try: - if self.script_type == SettingsConstants.CUSTOM_DERIVATION: - derivation_path = self.custom_derivation - else: - derivation_path = PSBTParser.calc_derivation( - network=self.settings.get_value(SettingsConstants.SETTING__NETWORK), - wallet_type=self.sig_type, - script_type=self.script_type - ) + if self.settings.get_value(SettingsConstants.SETTING__XPUB_DETAILS) == SettingsConstants.OPTION__DISABLED: + # We're just skipping right past this screen + selected_menu_num = 0 - if self.settings.get_value(SettingsConstants.SETTING__XPUB_DETAILS) == SettingsConstants.OPTION__ENABLED: + else: + # The derivation calc takes a few moments. Run the loading screen while we wait. + from seedsigner.gui.screens.screen import LoadingScreenThread + self.loading_screen = LoadingScreenThread(text="Generating xpub...") + self.loading_screen.start() + + 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, version=embit_network["xprv"] ) - fingerprint = hexlify(root.child(0).fingerprint).decode('utf-8') xprv = root.derive(derivation_path) xpub = xprv.to_public() xpub_base58 = xpub.to_string(version=version) - screen = seed_screens.SeedExportXpubDetailsScreen( - fingerprint=fingerprint, - has_passphrase=self.seed.passphrase is not None, - derivation_path=derivation_path, - xpub=xpub_base58, - ) - + finally: self.loading_screen.stop() - selected_menu_num = screen.display() - - else: - selected_menu_num = 0 - - finally: - self.loading_screen.stop() + selected_menu_num = self.run_screen( + seed_screens.SeedExportXpubDetailsScreen, + fingerprint=fingerprint, + has_passphrase=self.seed.passphrase is not None, + derivation_path=derivation_path, + xpub=xpub_base58, + ) if selected_menu_num == 0: return Destination( SeedExportXpubQRDisplayView, - { - "seed_num": self.seed_num, - "sig_type": self.sig_type, - "script_type": self.script_type, - "coordinator": self.coordinator, - "derivation_path": derivation_path, - } + dict(seed_num=self.seed_num, + coordinator=self.coordinator, + derivation_path=derivation_path, + sig_type=self.sig_type + ) ) elif selected_menu_num == RET_CODE__BACK_BUTTON: @@ -710,39 +950,35 @@ class SeedExportXpubDetailsView(View): class SeedExportXpubQRDisplayView(View): - def __init__(self, seed_num: int, sig_type: str, script_type: str, 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) - qr_density = self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY) - if coordinator == SettingsConstants.COORDINATOR__SPECTER_DESKTOP: - qr_type = QRType.XPUB__SPECTER - - elif coordinator == SettingsConstants.COORDINATOR__BLUE_WALLET: - qr_type = QRType.XPUB - - elif coordinator == SettingsConstants.COORDINATOR__NUNCHUK: - qr_type = QRType.XPUB__UR - - # As of 2022-03-02 Nunchuk doesn't seem to support animated QRs for Xpub import - qr_density = SettingsConstants.DENSITY__HIGH - - else: - qr_type = QRType.XPUB__UR - - self.qr_encoder = EncodeQR( - seed_phrase=self.seed.mnemonic_list, - passphrase=self.seed.passphrase, + encoder_args = dict( + seed=self.seed, derivation=derivation_path, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK), - qr_type=qr_type, - qr_density=qr_density, - wordlist_language_code=self.seed.wordlist_language_code + qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY), + sig_type=sig_type ) + if coordinator == SettingsConstants.COORDINATOR__SPECTER_DESKTOP: + self.qr_encoder = SpecterXPubQrEncoder(**encoder_args) + + elif coordinator in [SettingsConstants.COORDINATOR__BLUE_WALLET, + SettingsConstants.COORDINATOR__KEEPER, + SettingsConstants.COORDINATOR__NUNCHUK]: + self.qr_encoder = StaticXpubQrEncoder(**encoder_args) + + else: + self.qr_encoder = UrXpubQrEncoder(**encoder_args) + def run(self): - QRDisplayScreen(qr_encoder=self.qr_encoder).display() + self.run_screen( + QRDisplayScreen, + qr_encoder=self.qr_encoder + ) return Destination(MainMenuView) @@ -752,24 +988,30 @@ class SeedExportXpubQRDisplayView(View): View Seed Words flow ****************************************************************************""" class SeedWordsWarningView(View): - def __init__(self, seed_num: int): + def __init__(self, seed_num: int, bip85_data: dict = None): super().__init__() self.seed_num = seed_num + self.bip85_data = bip85_data def run(self): destination = Destination( SeedWordsView, - view_args={"seed_num": self.seed_num, "page_index": 0}, + view_args=dict( + seed_num=self.seed_num, + page_index=0, + bip85_data=self.bip85_data + ), skip_current_view=True, # Prevent going BACK to WarningViews ) if self.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__DISABLED: # Forward straight to showing the words return destination - selected_menu_num = DireWarningScreen( - text="""You must keep your seed words private & away from all online devices.""", - ).display() + selected_menu_num = self.run_screen( + DireWarningScreen, + text="""Never input your seed phrase into a device that connects to the internet.""", + ) if selected_menu_num == 0: # User clicked "I Understand" @@ -781,15 +1023,15 @@ class SeedWordsWarningView(View): class SeedWordsView(View): - def __init__(self, seed_num: int, page_index: int = 0): + def __init__(self, seed_num: int, bip85_data: dict = None, page_index: int = 0): super().__init__() self.seed_num = seed_num if self.seed_num is None: self.seed = self.controller.storage.get_pending_seed() else: self.seed = self.controller.get_seed(self.seed_num) + self.bip85_data = bip85_data self.page_index = page_index - self.num_pages=int(len(self.seed.mnemonic_list)/4) def run(self): @@ -799,20 +1041,26 @@ class SeedWordsView(View): # Slice the mnemonic to our current 4-word section words_per_page = 4 # TODO: eventually make this configurable for bigger screens? - mnemonic = self.seed.mnemonic_display_list + if self.bip85_data is not None: + mnemonic = self.seed.get_bip85_child_mnemonic(self.bip85_data["child_index"], self.bip85_data["num_words"]).split() + title = f"""Child #{self.bip85_data["child_index"]}""" + else: + mnemonic = self.seed.mnemonic_display_list + title = "Seed Words" words = mnemonic[self.page_index*words_per_page:(self.page_index + 1)*words_per_page] button_data = [] - if self.page_index < self.num_pages - 1 or self.seed_num is None: + num_pages = int(len(mnemonic)/words_per_page) + if self.page_index < num_pages - 1 or self.seed_num is None: button_data.append(NEXT) else: button_data.append(DONE) selected_menu_num = seed_screens.SeedWordsScreen( - title=f"Seed Words: {self.page_index+1}/{self.num_pages}", + title=f"{title}: {self.page_index+1}/{num_pages}", words=words, page_index=self.page_index, - num_pages=self.num_pages, + num_pages=num_pages, button_data=button_data, ).display() @@ -820,14 +1068,129 @@ class SeedWordsView(View): return Destination(BackStackView) if button_data[selected_menu_num] == NEXT: - if self.seed_num is None and self.page_index == self.num_pages - 1: - return Destination(SeedWordsBackupTestPromptView, view_args=dict(seed_num=self.seed_num)) + if self.seed_num is None and self.page_index == num_pages - 1: + return Destination( + SeedWordsBackupTestPromptView, + view_args=dict(seed_num=self.seed_num, bip85_data=self.bip85_data), + ) else: - return Destination(SeedWordsView, view_args=dict(seed_num=self.seed_num, page_index=self.page_index + 1)) + return Destination( + SeedWordsView, + view_args=dict(seed_num=self.seed_num, page_index=self.page_index + 1, bip85_data=self.bip85_data) + ) elif button_data[selected_menu_num] == DONE: # Must clear history to avoid BACK button returning to private info - return Destination(SeedWordsBackupTestPromptView, view_args=dict(seed_num=self.seed_num)) + return Destination( + SeedWordsBackupTestPromptView, + view_args=dict(seed_num=self.seed_num, bip85_data=self.bip85_data), + ) + + + +"""**************************************************************************** + BIP85 - Derive child mnemonic (seed) flow +****************************************************************************""" +class SeedBIP85ApplicationModeView(View): + """ + * Ask the user the application type as defined in the BIP0085 spec. + * Currently only Word mode of 12, 24 words (Application number: 39') + * Possible future additions are + * WIF (HDSEED) + * XPRV (BIP32) + """ + def __init__(self, seed_num: int): + super().__init__() + self.seed_num = seed_num + self.num_words = 0 + self.bip85_app_num = 39 # TODO: Support other Application numbers + + + def run(self): + # TODO: Future enhancement to display WIF (HD-SEED) and XPRV (Bip32)? + WORDS_12 = "12 Words" + WORDS_24 = "24 Words" + + button_data = [WORDS_12, WORDS_24] + + selected_menu_num = ButtonListScreen( + title="BIP-85 Num Words", + button_data=button_data + ).display() + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + if button_data[selected_menu_num] == WORDS_12: + self.num_words = 12 + elif button_data[selected_menu_num] == WORDS_24: + self.num_words = 24 + + return Destination( + SeedBIP85SelectChildIndexView, + view_args=dict(seed_num=self.seed_num, num_words=self.num_words) + ) + + + +# View to retrieve the derived seed index +class SeedBIP85SelectChildIndexView(View): + def __init__(self, seed_num: int, num_words: int): + super().__init__() + self.seed_num = seed_num + self.num_words = num_words + + + def run(self): + # Change this later to use the generic Screen input keyboard + ret = seed_screens.SeedBIP85SelectChildIndexScreen().display() + + if ret == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + if not 0 <= int(ret) < 2**31: + return Destination( + SeedBIP85InvalidChildIndexView, + view_args=dict( + seed_num=self.seed_num, + num_words=self.num_words + ), + skip_current_view=True + ) + + return Destination( + SeedWordsWarningView, + view_args=dict( + seed_num=self.seed_num, + bip85_data=dict(child_index=int(ret), num_words=self.num_words), + ) + ) + + +class SeedBIP85InvalidChildIndexView(View): + def __init__(self, seed_num: int, num_words: int): + super().__init__() + self.seed_num = seed_num + self.num_words = num_words + + + def run(self): + DireWarningScreen( + title="BIP-85 Index Error", + show_back_button=False, + status_headline=f"Invalid Child Index", + text=f"BIP-85 Child Index must be between 0 and {2**31-1}.", + button_data=["Try Again"] + ).display() + + return Destination( + SeedBIP85SelectChildIndexView, + view_args=dict( + seed_num=self.seed_num, + num_words=self.num_words + ), + skip_current_view=True + ) @@ -835,9 +1198,11 @@ class SeedWordsView(View): Seed Words Backup Test ****************************************************************************""" class SeedWordsBackupTestPromptView(View): - def __init__(self, seed_num: int): + def __init__(self, seed_num: int, bip85_data: dict = None): + super().__init__() self.seed_num = seed_num - + self.bip85_data = bip85_data + def run(self): VERIFY = "Verify" @@ -848,7 +1213,10 @@ class SeedWordsBackupTestPromptView(View): ).display() if button_data[selected_menu_num] == VERIFY: - return Destination(SeedWordsBackupTestView, view_args=dict(seed_num=self.seed_num)) + return Destination( + SeedWordsBackupTestView, + view_args=dict(seed_num=self.seed_num, bip85_data=self.bip85_data), + ) elif button_data[selected_menu_num] == SKIP: if self.seed_num is not None: @@ -859,19 +1227,24 @@ class SeedWordsBackupTestPromptView(View): class SeedWordsBackupTestView(View): - def __init__(self, seed_num: int, confirmed_list: List[bool] = None, cur_index: int = None): + def __init__(self, seed_num: int, bip85_data: dict = None, confirmed_list: List[bool] = None, cur_index: int = None): super().__init__() self.seed_num = seed_num if self.seed_num is None: self.seed = self.controller.storage.get_pending_seed() else: self.seed = self.controller.get_seed(self.seed_num) + self.bip85_data = bip85_data + + if self.bip85_data is not None: + self.mnemonic_list = self.seed.get_bip85_child_mnemonic(self.bip85_data["child_index"], self.bip85_data["num_words"]).split() + else: + self.mnemonic_list = self.seed.mnemonic_display_list - self.mnemonic_list = self.seed.mnemonic_display_list self.confirmed_list = confirmed_list if not self.confirmed_list: self.confirmed_list = [] - + self.cur_index = cur_index @@ -880,7 +1253,7 @@ class SeedWordsBackupTestView(View): self.cur_index = int(random.random() * len(self.mnemonic_list)) while self.cur_index in self.confirmed_list: self.cur_index = int(random.random() * len(self.mnemonic_list)) - + real_word = self.mnemonic_list[self.cur_index] fake_word1 = bip39.WORDLIST[int(random.random() * 2047)] fake_word2 = bip39.WORDLIST[int(random.random() * 2047)] @@ -901,17 +1274,24 @@ class SeedWordsBackupTestView(View): self.confirmed_list.append(self.cur_index) if len(self.confirmed_list) == len(self.mnemonic_list): # Successfully confirmed the full mnemonic! - return Destination(SeedWordsBackupTestSuccessView, view_args=dict(seed_num=self.seed_num)) + return Destination( + SeedWordsBackupTestSuccessView, + view_args=dict(seed_num=self.seed_num), + ) else: # Continue testing the remaining words - return Destination(SeedWordsBackupTestView, view_args=dict(seed_num=self.seed_num, confirmed_list=self.confirmed_list)) - + return Destination( + SeedWordsBackupTestView, + view_args=dict(seed_num=self.seed_num, confirmed_list=self.confirmed_list, bip85_data=self.bip85_data), + ) + else: # Picked the WRONG WORD! return Destination( SeedWordsBackupTestMistakeView, view_args=dict( seed_num=self.seed_num, + bip85_data=self.bip85_data, cur_index=self.cur_index, wrong_word=button_data[selected_menu_num], confirmed_list=self.confirmed_list, @@ -921,14 +1301,15 @@ class SeedWordsBackupTestView(View): class SeedWordsBackupTestMistakeView(View): - def __init__(self, seed_num: int, cur_index: int, wrong_word: str, confirmed_list: List[bool] = None): + def __init__(self, seed_num: int, bip85_data: dict = None, cur_index: int = None, wrong_word: str = None, confirmed_list: List[bool] = None): super().__init__() self.seed_num = seed_num + self.bip85_data = bip85_data self.cur_index = cur_index self.wrong_word = wrong_word self.confirmed_list = confirmed_list - + def run(self): REVIEW = "Review Seed Words" RETRY = "Try Again" @@ -943,17 +1324,29 @@ class SeedWordsBackupTestMistakeView(View): ).display() if button_data[selected_menu_num] == REVIEW: - return Destination(SeedWordsView, view_args=dict(seed_num=self.seed_num)) - + return Destination( + SeedWordsView, + view_args=dict(seed_num=self.seed_num, bip85_data=self.bip85_data), + ) + elif button_data[selected_menu_num] == RETRY: - return Destination(SeedWordsBackupTestView, view_args=dict(seed_num=self.seed_num, confirmed_list=self.confirmed_list, cur_index=self.cur_index)) - + return Destination( + SeedWordsBackupTestView, + view_args=dict( + seed_num=self.seed_num, + confirmed_list=self.confirmed_list, + cur_index=self.cur_index, + bip85_data=self.bip85_data, + ) + ) + class SeedWordsBackupTestSuccessView(View): def __init__(self, seed_num: int): + super().__init__() self.seed_num = seed_num - + def run(self): LargeIconStatusScreen( title="Backup Verified", @@ -1057,7 +1450,7 @@ class SeedTranscribeSeedQRWarningView(View): selected_menu_num = DireWarningScreen( status_headline="SeedQR is your private key!", - text="""Never photograph it or scan it into an online device.""", + text="""Never photograph or scan it into a device that connects to the internet.""", ).display() if selected_menu_num == RET_CODE__BACK_BUTTON: @@ -1079,11 +1472,13 @@ class SeedTranscribeSeedQRWholeQRView(View): def run(self): - e = EncodeQR( - seed_phrase=self.seed.mnemonic_list, - qr_type=self.seedqr_format, - wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) - ) + encoder_args = dict(mnemonic=self.seed.mnemonic_list, + wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)) + if self.seedqr_format == QRType.SEED__SEEDQR: + e = SeedQrEncoder(**encoder_args) + elif self.seedqr_format == QRType.SEED__COMPACTSEEDQR: + e = CompactSeedQrEncoder(**encoder_args) + data = e.next_part() ret = seed_screens.SeedTranscribeSeedQRWholeQRScreen( @@ -1114,11 +1509,13 @@ class SeedTranscribeSeedQRZoomedInView(View): def run(self): - e = EncodeQR( - seed_phrase=self.seed.mnemonic_list, - qr_type=self.seedqr_format, - wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) - ) + encoder_args = dict(mnemonic=self.seed.mnemonic_list, + wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)) + if self.seedqr_format == QRType.SEED__SEEDQR: + e = SeedQrEncoder(**encoder_args) + elif self.seedqr_format == QRType.SEED__COMPACTSEEDQR: + e = CompactSeedQrEncoder(**encoder_args) + data = e.next_part() if len(self.seed.mnemonic_list) == 24: @@ -1149,7 +1546,7 @@ class SeedTranscribeSeedQRConfirmQRPromptView(View): def run(self): - SCAN = ("Confirm SeedQR", FontAwesomeIconConstants.QRCODE) + SCAN = ("Confirm SeedQR", SeedSignerIconConstants.QRCODE) DONE = "Done" button_data = [SCAN, DONE] @@ -1238,6 +1635,11 @@ class AddressVerificationStartView(View): def run(self): + if self.controller.unverified_address["script_type"] == SettingsConstants.LEGACY_P2PKH: + # Legacy P2PKH addresses are always singlesig + sig_type = SettingsConstants.SINGLE_SIG + destination = Destination(SeedSelectSeedView, skip_current_view=True) + if self.controller.unverified_address["script_type"] == SettingsConstants.NESTED_SEGWIT: # No way to differentiate single sig from multisig return Destination(AddressVerificationSigTypeView, skip_current_view=True) @@ -1248,23 +1650,20 @@ class AddressVerificationStartView(View): sig_type = SettingsConstants.MULTISIG if self.controller.multisig_wallet_descriptor: # Can jump straight to the brute-force verification View - destination = Destination(SeedAddressVerificationView) + destination = Destination(SeedAddressVerificationView, skip_current_view=True) else: self.controller.resume_main_flow = Controller.FLOW__VERIFY_MULTISIG_ADDR - destination = Destination(LoadMultisigWalletDescriptorView) + destination = Destination(LoadMultisigWalletDescriptorView, skip_current_view=True) else: sig_type = SettingsConstants.SINGLE_SIG - destination = Destination(SeedSingleSigAddressVerificationSelectSeedView) + destination = Destination(SeedSelectSeedView, skip_current_view=True) elif self.controller.unverified_address["script_type"] == SettingsConstants.TAPROOT: - destination = Destination(NotYetImplementedView) + # TODO: add Taproot support + return Destination(NotYetImplementedView) - elif self.controller.unverified_address["script_type"] == SettingsConstants.LEGACY_P2PKH: - # TODO: detect single sig vs multisig or have to prompt? - destination = Destination(NotYetImplementedView) - - derivation_path = PSBTParser.calc_derivation( + derivation_path = embit_utils.get_standard_derivation_path( network=self.controller.unverified_address["network"], wallet_type=sig_type, script_type=self.controller.unverified_address["script_type"] @@ -1278,28 +1677,28 @@ class AddressVerificationStartView(View): class AddressVerificationSigTypeView(View): - def run(self): - sig_type_settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SIG_TYPES) - SINGLE_SIG = sig_type_settings_entry.get_selection_option_display_name_by_value(SettingsConstants.SINGLE_SIG) - MULTISIG = sig_type_settings_entry.get_selection_option_display_name_by_value(SettingsConstants.MULTISIG) + SINGLE_SIG = "Single Sig" + MULTISIG = "Multisig" - button_data = [SINGLE_SIG, MULTISIG] - selected_menu_num = seed_screens.AddressVerificationSigTypeScreen( + def run(self): + button_data = [self.SINGLE_SIG, self.MULTISIG] + selected_menu_num = self.run_screen( + seed_screens.AddressVerificationSigTypeScreen, title="Verify Address", text="Sig type can't be auto-detected from this address. Please specify:", button_data=button_data, is_bottom_list=True, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: self.controller.unverified_address = None return Destination(BackStackView) - elif button_data[selected_menu_num] == SINGLE_SIG: + elif button_data[selected_menu_num] == self.SINGLE_SIG: sig_type = SettingsConstants.SINGLE_SIG - destination = Destination(SeedSingleSigAddressVerificationSelectSeedView) + destination = Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__VERIFY_SINGLESIG_ADDR)) - elif button_data[selected_menu_num] == MULTISIG: + elif button_data[selected_menu_num] == self.MULTISIG: sig_type = SettingsConstants.MULTISIG if self.controller.multisig_wallet_descriptor: destination = Destination(SeedAddressVerificationView) @@ -1308,7 +1707,7 @@ class AddressVerificationSigTypeView(View): destination = Destination(LoadMultisigWalletDescriptorView) self.controller.unverified_address["sig_type"] = sig_type - derivation_path = PSBTParser.calc_derivation( + derivation_path = embit_utils.get_standard_derivation_path( network=self.controller.unverified_address["network"], wallet_type=sig_type, script_type=self.controller.unverified_address["script_type"] @@ -1319,66 +1718,6 @@ class AddressVerificationSigTypeView(View): -class SeedSingleSigAddressVerificationSelectSeedView(View): - def run(self): - seeds = self.controller.storage.seeds - - SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - button_data = [] - - text = "Load the seed to verify" - - for seed in seeds: - button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - - if seed.passphrase is not None: - # TODO: Include lock icon on right side of button - pass - button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) - - text = "Select seed to verify" - - button_data.append(SCAN_SEED) - button_data.append(TYPE_12WORD) - button_data.append(TYPE_24WORD) - - selected_menu_num = seed_screens.SeedSingleSigAddressVerificationSelectSeedScreen( - title="Verify Address", - text=text, - is_button_text_centered=False, - button_data=button_data - ).display() - - if selected_menu_num == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) - - if len(seeds) > 0 and selected_menu_num < len(seeds): - # User selected one of the n seeds - return Destination( - SeedAddressVerificationView, - view_args=dict( - seed_num=selected_menu_num, - ) - ) - - self.controller.resume_main_flow = Controller.FLOW__VERIFY_SINGLESIG_ADDR - - if button_data[selected_menu_num] == SCAN_SEED: - from seedsigner.views.scan_views import ScanView - return Destination(ScanView) - - elif button_data[selected_menu_num] in [TYPE_12WORD, TYPE_24WORD]: - from seedsigner.views.seed_views import SeedMnemonicEntryView - if button_data[selected_menu_num] == TYPE_12WORD: - self.controller.storage.init_pending_mnemonic(num_words=12) - else: - self.controller.storage.init_pending_mnemonic(num_words=24) - return Destination(SeedMnemonicEntryView) - - - class SeedAddressVerificationView(View): """ Creates a worker thread to brute-force calculate addresses. Writes its @@ -1394,15 +1733,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(sig_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"] @@ -1412,7 +1753,7 @@ class SeedAddressVerificationView(View): return Destination(NotYetImplementedView) # TODO: This should be in `Seed` or `PSBT` utility class - embit_network = NETWORKS[SettingsConstants.map_network_to_embit(self.network)] + embit_network = SettingsConstants.map_network_to_embit(self.network) # The ThreadsafeCounter will be shared by the brute-force thread to keep track of # its current addr index number and the Screen to display its progress and @@ -1429,7 +1770,7 @@ class SeedAddressVerificationView(View): seed=self.seed, descriptor=self.controller.multisig_wallet_descriptor, script_type=self.script_type, - network=embit_network, + embit_network=embit_network, derivation_path=self.derivation_path, threadsafe_counter=self.threadsafe_counter, verified_index=self.verified_index, @@ -1460,7 +1801,8 @@ class SeedAddressVerificationView(View): # and resume displaying the screen. User won't even notice that the Screen is # being re-constructed. while True: - selected_menu_num = seed_screens.SeedAddressVerificationScreen( + selected_menu_num = self.run_screen( + seed_screens.SeedAddressVerificationScreen, address=self.address, derivation_path=self.derivation_path, script_type=script_type_display, @@ -1470,7 +1812,7 @@ class SeedAddressVerificationView(View): threadsafe_counter=self.threadsafe_counter, verified_index=self.verified_index, button_data=button_data, - ).display() + ) if self.verified_index.cur_count is not None: break @@ -1478,6 +1820,12 @@ class SeedAddressVerificationView(View): if selected_menu_num == RET_CODE__BACK_BUTTON: break + if selected_menu_num is None: + # Only happens in the test suite; the screen isn't actually executed so + # it returns before the brute force thread has completed. + time.sleep(0.1) + continue + if button_data[selected_menu_num] == SKIP_10: self.threadsafe_counter.increment(10) @@ -1494,13 +1842,15 @@ class SeedAddressVerificationView(View): # Halt the thread if the user gave up (will already be stopped if it verified the # target addr). self.addr_verification_thread.stop() + while self.addr_verification_thread.is_alive(): + time.sleep(0.01) return Destination(MainMenuView) class BruteForceAddressVerificationThread(BaseThread): - def __init__(self, address: str, seed: Seed, descriptor: Descriptor, script_type: str, network: str, derivation_path: str, threadsafe_counter: ThreadsafeCounter, verified_index: ThreadsafeCounter, verified_index_is_change: ThreadsafeCounter): + def __init__(self, address: str, seed: Seed, descriptor: Descriptor, script_type: str, embit_network: str, derivation_path: str, threadsafe_counter: ThreadsafeCounter, verified_index: ThreadsafeCounter, verified_index_is_change: ThreadsafeCounter): """ Either seed or descriptor will be None """ @@ -1509,29 +1859,30 @@ class SeedAddressVerificationView(View): self.seed = seed self.descriptor = descriptor self.script_type = script_type - self.network = network + self.embit_network = embit_network self.derivation_path = derivation_path self.threadsafe_counter = threadsafe_counter self.verified_index = verified_index self.verified_index_is_change = verified_index_is_change if self.seed: - root = embit.bip32.HDKey.from_seed(self.seed.seed_bytes, version=network["xprv"]) - xprv = root.derive(self.derivation_path) - self.xpub = xprv.to_public() - + self.xpub = self.seed.get_xpub(wallet_path=self.derivation_path, network=Settings.get_instance().get_value(SettingsConstants.SETTING__NETWORK)) + def run(self): while self.keep_running: if self.threadsafe_counter.cur_count % 10 == 0: - print(f"Incremented to {self.threadsafe_counter.cur_count}") + logger.info(f"Incremented to {self.threadsafe_counter.cur_count}") i = self.threadsafe_counter.cur_count if self.descriptor: - (receive_address, change_address) = self.derive_multisig(i) + receive_address = embit_utils.get_multisig_address(descriptor=self.descriptor, index=i, is_change=False, embit_network=self.embit_network) + change_address = embit_utils.get_multisig_address(descriptor=self.descriptor, index=i, is_change=True, embit_network=self.embit_network) + else: - (receive_address, change_address) = self.derive_single_sig(i) + receive_address = embit_utils.get_single_sig_address(xpub=self.xpub, script_type=self.script_type, index=i, is_change=False, embit_network=self.embit_network) + change_address = embit_utils.get_single_sig_address(xpub=self.xpub, script_type=self.script_type, index=i, is_change=True, embit_network=self.embit_network) if self.address == receive_address: self.verified_index.set_value(i) @@ -1547,47 +1898,8 @@ class SeedAddressVerificationView(View): # Increment our index counter self.threadsafe_counter.increment() - - - def derive_single_sig(self, index): - r_pubkey = self.xpub.derive([0,index]).key - c_pubkey = self.xpub.derive([1,index]).key - - receive_address = "" - change_address = "" - - if self.script_type == SettingsConstants.NATIVE_SEGWIT: - receive_address = embit.script.p2wpkh(r_pubkey).address(network=self.network) - change_address = embit.script.p2wpkh(c_pubkey).address(network=self.network) - elif self.script_type == SettingsConstants.NESTED_SEGWIT: - receive_address = embit.script.p2sh(embit.script.p2wpkh(r_pubkey)).address(network=self.network) - change_address = embit.script.p2sh(embit.script.p2wpkh(c_pubkey)).address(network=self.network) - elif self.script_type == SettingsConstants.LEGACY_P2PKH: - receive_address = embit.script.p2pkh(r_pubkey).address(network=self.network) - change_address = embit.script.p2pkh(c_pubkey).address(network=self.network) - elif self.script_type == SettingsConstants.TAPROOT: - # TODO: Not yet implemented! - raise Exception("Taproot verification not yet implemented!") - - return (receive_address, change_address) - def derive_multisig(self, index): - if self.script_type in [SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT]: - receive_address = self.descriptor.derive(index, branch_index=0).script_pubkey().address(network=self.network) - change_address = self.descriptor.derive(index, branch_index=1).script_pubkey().address(network=self.network) - - elif self.script_type == SettingsConstants.LEGACY_P2PKH: - # TODO: Not yet implemented! - raise Exception("Taproot verification not yet implemented!") - - elif self.script_type == SettingsConstants.TAPROOT: - # TODO: Not yet implemented! - raise Exception("Taproot verification not yet implemented!") - - return (receive_address, change_address) - - class AddressVerificationSuccessView(View): def __init__(self, seed_num: int): @@ -1610,7 +1922,8 @@ class AddressVerificationSuccessView(View): LargeIconStatusScreen( status_headline="Address Verified", - text=f"""{address[:7]} = {source}'s {"change" if verified_index_is_change else "receive"} address #{verified_index}.""" + text=f"""{address[:7]} = {source}'s {"change" if verified_index_is_change else "receive"} address #{verified_index}.""", + show_back_button=False, ).display() return Destination(MainMenuView) @@ -1618,19 +1931,25 @@ class AddressVerificationSuccessView(View): class LoadMultisigWalletDescriptorView(View): + SCAN = ("Scan Descriptor", SeedSignerIconConstants.QRCODE) + CANCEL = "Cancel" + def run(self): - SCAN = ("Scan Descriptor", FontAwesomeIconConstants.QRCODE) - CANCEL = "Cancel" - button_data = [SCAN, CANCEL] - selected_menu_num = seed_screens.LoadMultisigWalletDescriptorScreen( + button_data = [ + self.SCAN, + self.CANCEL + ] + selected_menu_num = self.run_screen( + seed_screens.LoadMultisigWalletDescriptorScreen, button_data=button_data, show_back_button=False, - ).display() + ) - if button_data[selected_menu_num] == SCAN: - return Destination(ScanView) - - elif button_data[selected_menu_num] == CANCEL: + if button_data[selected_menu_num] == self.SCAN: + from seedsigner.views.scan_views import ScanWalletDescriptorView + return Destination(ScanWalletDescriptorView) + + elif button_data[selected_menu_num] == self.CANCEL: if self.controller.resume_main_flow == Controller.FLOW__PSBT: return Destination(BackStackView) else: @@ -1639,6 +1958,11 @@ class LoadMultisigWalletDescriptorView(View): class MultisigWalletDescriptorView(View): + RETURN = "Return to PSBT" + VERIFY_ADDR = "Verify Addr" + ADDRESS_EXPLORER = "Address Explorer" + OK = "OK" + def run(self): descriptor = self.controller.multisig_wallet_descriptor @@ -1648,37 +1972,216 @@ class MultisigWalletDescriptorView(View): fingerprints.append(fingerprint) policy = descriptor.brief_policy.split("multisig")[0].strip() - - RETURN = "Return to PSBT" - VERIFY = "Verify Addr" - OK = "OK" - button_data = [OK] + button_data = [self.OK] if self.controller.resume_main_flow: if self.controller.resume_main_flow == Controller.FLOW__PSBT: - button_data = [RETURN] + button_data = [self.RETURN] elif self.controller.resume_main_flow == Controller.FLOW__VERIFY_MULTISIG_ADDR and self.controller.unverified_address: - VERIFY += f""" {self.controller.unverified_address["address"][:7]}""" - button_data = [VERIFY] + verify_addr_display = f"""{self.VERIFY_ADDR} {self.controller.unverified_address["address"][:7]}""" + button_data = [verify_addr_display] + elif self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: + button_data = [self.ADDRESS_EXPLORER] - selected_menu_num = seed_screens.MultisigWalletDescriptorScreen( + selected_menu_num = self.run_screen( + seed_screens.MultisigWalletDescriptorScreen, policy=policy, fingerprints=fingerprints, button_data=button_data, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: self.controller.multisig_wallet_descriptor = None return Destination(BackStackView) - elif button_data[selected_menu_num] == RETURN: + elif button_data[selected_menu_num] == self.RETURN: # Jump straight back to PSBT change verification + from seedsigner.views.psbt_views import PSBTChangeDetailsView self.controller.resume_main_flow = None return Destination(PSBTChangeDetailsView, view_args=dict(change_address_num=0)) - elif button_data[selected_menu_num] == VERIFY: + elif button_data[selected_menu_num].startswith(self.VERIFY_ADDR): self.controller.resume_main_flow = None - # TODO: Route properly when multisig brute-force addr verification is done return Destination(SeedAddressVerificationView) + elif button_data[selected_menu_num] == self.ADDRESS_EXPLORER: + from seedsigner.views.tools_views import ToolsAddressExplorerAddressTypeView + self.controller.resume_main_flow = None + return Destination(ToolsAddressExplorerAddressTypeView) + return Destination(MainMenuView) + + + +"""**************************************************************************** + Sign Message Views +****************************************************************************""" +class SeedSignMessageStartView(View): + """ + Routes users straight through to the "Sign" screen if a signing `seed_num` has + already been selected. Otherwise routes to `SeedSelectSeedView` to select or + load a seed first. + """ + def __init__(self, derivation_path: str, message: str): + super().__init__() + self.derivation_path = derivation_path + self.message = message + + if self.settings.get_value(SettingsConstants.SETTING__MESSAGE_SIGNING) == SettingsConstants.OPTION__DISABLED: + self.set_redirect(Destination(OptionDisabledView, view_args=dict(settings_attr=SettingsConstants.SETTING__MESSAGE_SIGNING))) + return + + # calculate the actual receive address + addr_format = embit_utils.parse_derivation_path(derivation_path) + if not addr_format["clean_match"]: + self.set_redirect(Destination(NotYetImplementedView, view_args=dict(text=f"Signing messages for custom derivation paths not supported"))) + self.controller.resume_main_flow = None + return + + # Note: addr_format["network"] can be MAINNET or [TESTNET, REGTEST] + if self.settings.get_value(SettingsConstants.SETTING__NETWORK) not in addr_format["network"]: + from seedsigner.views.view import NetworkMismatchErrorView + self.set_redirect(Destination(NetworkMismatchErrorView, view_args=dict(text=f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match {self.derivation_path}"))) + + # cleanup. Note: We could leave this in place so the user can resume the + # flow, but for now we avoid complications and keep things simple. + self.controller.resume_main_flow = None + return + + data = self.controller.sign_message_data + if not data: + data = {} + self.controller.sign_message_data = data + data["derivation_path"] = derivation_path + data["message"] = message + data["addr_format"] = addr_format + + # May be None + self.seed_num = data.get("seed_num") + + if self.seed_num is not None: + # We already know which seed we're signing with + self.set_redirect(Destination(SeedSignMessageConfirmMessageView, skip_current_view=True)) + else: + self.set_redirect(Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__SIGN_MESSAGE), skip_current_view=True)) + + + +class SeedSignMessageConfirmMessageView(View): + def __init__(self, page_num: int = 0): + super().__init__() + self.page_num = page_num # Note: zero-indexed numbering! + + self.seed_num = self.controller.sign_message_data.get("seed_num") + if self.seed_num is None: + raise Exception("Routing error: seed_num hasn't been set") + + + def run(self): + from seedsigner.gui.screens.seed_screens import SeedSignMessageConfirmMessageScreen + + selected_menu_num = self.run_screen( + SeedSignMessageConfirmMessageScreen, + page_num=self.page_num, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + if self.page_num == 0: + # We're exiting this flow entirely + self.controller.resume_main_flow = None + self.controller.sign_message_data = None + return Destination(BackStackView) + + # User clicked "Next" + if self.page_num == len(self.controller.sign_message_data["paged_message"]) - 1: + # We've reached the end of the paged message + return Destination(SeedSignMessageConfirmAddressView) + else: + return Destination(SeedSignMessageConfirmMessageView, view_args=dict(page_num=self.page_num + 1)) + + + +class SeedSignMessageConfirmAddressView(View): + def __init__(self): + super().__init__() + data = self.controller.sign_message_data + seed_num = data.get("seed_num") + self.derivation_path = data.get("derivation_path") + + if seed_num is None or not self.derivation_path: + raise Exception("Routing error: sign_message_data hasn't been set") + + seed = self.controller.storage.seeds[seed_num] + addr_format = data.get("addr_format") + + # calculate the actual receive address + seed = self.controller.storage.seeds[seed_num] + addr_format = embit_utils.parse_derivation_path(self.derivation_path) + if not addr_format["clean_match"] or addr_format["script_type"] == SettingsConstants.CUSTOM_DERIVATION: + raise Exception("Signing messages for custom derivation paths not supported") + + if addr_format["network"] != SettingsConstants.MAINNET: + # We're in either Testnet or Regtest or...? + if self.settings.get_value(SettingsConstants.SETTING__NETWORK) in [SettingsConstants.TESTNET, SettingsConstants.REGTEST]: + addr_format["network"] = self.settings.get_value(SettingsConstants.SETTING__NETWORK) + else: + from seedsigner.views.view import NetworkMismatchErrorView + self.set_redirect(Destination(NetworkMismatchErrorView, view_args=dict(text=f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match {self.derivation_path}"))) + + # cleanup. Note: We could leave this in place so the user can resume the + # flow, but for now we avoid complications and keep things simple. + self.controller.resume_main_flow = None + self.controller.sign_message_data = None + return + + xpub = seed.get_xpub(wallet_path=addr_format["wallet_derivation_path"], network=addr_format["network"]) + embit_network = embit_utils.get_embit_network_name(addr_format["network"]) + self.address = embit_utils.get_single_sig_address(xpub=xpub, script_type=addr_format["script_type"], index=addr_format["index"], is_change=addr_format["is_change"], embit_network=embit_network) + + + def run(self): + from seedsigner.gui.screens.seed_screens import SeedSignMessageConfirmAddressScreen + selected_menu_num = self.run_screen( + SeedSignMessageConfirmAddressScreen, + derivation_path=self.derivation_path, + address=self.address, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + # User clicked "Sign Message" + return Destination(SeedSignMessageSignedMessageQRView) + + + +class SeedSignMessageSignedMessageQRView(View): + """ + Displays the signed message as a QR code. + """ + def __init__(self): + super().__init__() + data = self.controller.sign_message_data + + self.seed_num = data["seed_num"] + seed = self.controller.get_seed(self.seed_num) + derivation_path = data["derivation_path"] + message: str = data["message"] + + self.signed_message = embit_utils.sign_message(seed_bytes=seed.seed_bytes, derivation=derivation_path, msg=message.encode()) + + + def run(self): + qr_encoder = GenericStaticQrEncoder(data=self.signed_message) + + self.run_screen( + QRDisplayScreen, + qr_encoder=qr_encoder, + ) + + # cleanup + self.controller.resume_main_flow = None + self.controller.sign_message_data = None + + # Exiting/Canceling the QR display screen always returns Home + return Destination(MainMenuView, skip_current_view=True) diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 9aeb0dcb..e2a742ca 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -1,26 +1,32 @@ -from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants -from seedsigner.models.decode_qr import DecodeQR +import logging +from seedsigner.gui.components import SeedSignerIconConstants +from seedsigner.hardware.microsd import MicroSD -from .view import View, Destination, BackStackView, MainMenuView +from .view import View, Destination, MainMenuView from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens) -from seedsigner.models.settings import SettingsConstants, SettingsDefinition +from seedsigner.models.settings import Settings, SettingsConstants, SettingsDefinition + +logger = logging.getLogger(__name__) class SettingsMenuView(View): - def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None): + IO_TEST = "I/O test" + DONATE = "Donate" + + def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None, initial_scroll: int = 0): super().__init__() self.visibility = visibility self.selected_attr = selected_attr + # Used to preserve the rendering position in the list + self.initial_scroll = initial_scroll + def run(self): - IO_TEST = "I/O test" - DONATE = "Donate" - settings_entries = SettingsDefinition.get_settings_entries( - visibiilty=self.visibility + visibility=self.visibility ) button_data=[e.display_name for e in settings_entries] @@ -35,30 +41,35 @@ class SettingsMenuView(View): title = "Settings" # Set up the next nested level of menuing - button_data.append(("Advanced", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) - next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) + button_data.append(("Advanced", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT)) + next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) - button_data.append(IO_TEST) - button_data.append(DONATE) + button_data.append(self.IO_TEST) + button_data.append(self.DONATE) elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED: title = "Advanced" # So far there are no real Developer options; disabling for now - # button_data.append(("Developer Options", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) - # next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER}) - next = None + # button_data.append(("Developer Options", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT)) + # next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER}) + next_destination = None elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER: title = "Dev Options" - next = None + next_destination = None - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title=title, is_button_text_centered=False, button_data=button_data, selected_button=selected_button, - ).display() + scroll_y_initial_offset=self.initial_scroll, + ) + + # Preserve our scroll position in this Screen so we can return + initial_scroll = self.screen.buttons[0].scroll_y if selected_menu_num == RET_CODE__BACK_BUTTON: if self.visibility == SettingsConstants.VISIBILITY__GENERAL: @@ -69,17 +80,16 @@ class SettingsMenuView(View): return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) elif selected_menu_num == len(settings_entries): - return next + return next_destination - elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == IO_TEST: + elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.IO_TEST: return Destination(IOTestView) - elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == DONATE: + elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.DONATE: return Destination(DonateView) else: - # TODO: Free-entry types (are there any?) will need their own SettingsEntryUpdateFreeEntryView(?). - return Destination(SettingsEntryUpdateSelectionView, view_args={"attr_name": settings_entries[selected_menu_num].attr_name}) + return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=settings_entries[selected_menu_num].attr_name, parent_initial_scroll=initial_scroll)) @@ -88,10 +98,11 @@ class SettingsEntryUpdateSelectionView(View): Handles changes to all selection-type settings (Multiselect, SELECT_1, Enabled/Disabled, etc). """ - def __init__(self, attr_name: str): + def __init__(self, attr_name: str, parent_initial_scroll: int = 0, selected_button: int = None): super().__init__() self.settings_entry = SettingsDefinition.get_settings_entry(attr_name) - self.selected_button = None + self.selected_button = selected_button + self.parent_initial_scroll = parent_initial_scroll def run(self): @@ -103,7 +114,9 @@ class SettingsEntryUpdateSelectionView(View): value, display_name = value else: display_name = value + button_data.append(display_name) + if (type(initial_value) == list and value in initial_value) or value == initial_value: checked_buttons.append(i) @@ -112,24 +125,26 @@ class SettingsEntryUpdateSelectionView(View): # selected option). self.selected_button = i - if not self.selected_button: + if self.selected_button is None: self.selected_button = 0 - - ret_value = settings_screens.SettingsEntryUpdateSelectionScreen( + + ret_value = self.run_screen( + settings_screens.SettingsEntryUpdateSelectionScreen, display_name=self.settings_entry.display_name, help_text=self.settings_entry.help_text, button_data=button_data, selected_button=self.selected_button, checked_buttons=checked_buttons, settings_entry_type=self.settings_entry.type, - ).display() + ) destination = None settings_menu_view_destination = Destination( SettingsMenuView, view_args={ "visibility": self.settings_entry.visibility, - "selected_attr": self.settings_entry.attr_name + "selected_attr": self.settings_entry.attr_name, + "initial_scroll": self.parent_initial_scroll, } ) @@ -169,7 +184,37 @@ class SettingsEntryUpdateSelectionView(View): # All selects stay in place; re-initialize where in the list we left off self.selected_button = ret_value - return self.run() + + return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_entry.attr_name, parent_initial_scroll=self.parent_initial_scroll, selected_button=self.selected_button), skip_current_view=True) + + + +class SettingsIngestSettingsQRView(View): + def __init__(self, data: str): + super().__init__() + + # May raise an Exception which will bubble up to the Controller to display to the + # user. + self.config_name, settings_update_dict = Settings.parse_settingsqr(data) + + self.settings.update(settings_update_dict) + + if MicroSD.get_instance().is_inserted and self.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED: + self.status_message = "Persistent Settings enabled. Settings saved to SD card." + else: + self.status_message = "Settings updated in temporary memory" + + + def run(self): + from seedsigner.gui.screens.settings_screens import SettingsQRConfirmationScreen + self.run_screen( + SettingsQRConfirmationScreen, + config_name=self.config_name, + status_message=self.status_message, + ) + + # Only one exit point + return Destination(MainMenuView) @@ -178,7 +223,7 @@ class SettingsEntryUpdateSelectionView(View): ****************************************************************************""" class IOTestView(View): def run(self): - settings_screens.IOTestScreen().display() + self.run_screen(settings_screens.IOTestScreen) return Destination(SettingsMenuView) @@ -186,6 +231,6 @@ class IOTestView(View): class DonateView(View): def run(self): - settings_screens.DonateScreen().display() + self.run_screen(settings_screens.DonateScreen) return Destination(SettingsMenuView) diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index bfee4738..eabb1171 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -1,48 +1,66 @@ +from dataclasses import dataclass import hashlib +import logging import os import time +from embit.descriptor import Descriptor from PIL import Image from PIL.ImageOps import autocontrast -from seedsigner.hardware.camera import Camera -from seedsigner.gui.components import FontAwesomeIconConstants -from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen) -from seedsigner.gui.screens.tools_screens import ToolsCalcFinalWordFinalizePromptScreen, ToolsCalcFinalWordScreen, ToolsCoinFlipEntryScreen, ToolsDiceEntropyEntryScreen, ToolsImageEntropyFinalImageScreen, ToolsImageEntropyLivePreviewScreen, ToolsCalcFinalWordDoneScreen -from seedsigner.helpers import mnemonic_generation +from seedsigner.controller import Controller +from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerIconConstants +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) +from seedsigner.helpers import embit_utils, mnemonic_generation +from seedsigner.models.encode_qr import GenericStaticQrEncoder from seedsigner.models.seed import Seed from seedsigner.models.settings_definition import SettingsConstants -from seedsigner.views.seed_views import SeedDiscardView, SeedFinalizeView, SeedMnemonicEntryView, SeedWordsWarningView +from seedsigner.views.seed_views import SeedDiscardView, SeedFinalizeView, SeedMnemonicEntryView, SeedOptionsView, SeedWordsWarningView, SeedExportXpubScriptTypeView from .view import View, Destination, BackStackView +logger = logging.getLogger(__name__) class ToolsMenuView(View): + IMAGE = (" New seed", FontAwesomeIconConstants.CAMERA) + DICE = ("New seed", FontAwesomeIconConstants.DICE) + KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) + ADDRESS_EXPLORER = "Address Explorer" + VERIFY_ADDRESS = "Verify address" + def run(self): - IMAGE = (" New seed", FontAwesomeIconConstants.CAMERA) - DICE = ("New seed", FontAwesomeIconConstants.DICE) - KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) - button_data = [IMAGE, DICE, KEYBOARD] - screen = ButtonListScreen( + button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.ADDRESS_EXPLORER, self.VERIFY_ADDRESS] + + selected_menu_num = self.run_screen( + ButtonListScreen, title="Tools", is_button_text_centered=False, button_data=button_data ) - selected_menu_num = screen.display() if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == IMAGE: + elif button_data[selected_menu_num] == self.IMAGE: return Destination(ToolsImageEntropyLivePreviewView) - elif button_data[selected_menu_num] == DICE: + elif button_data[selected_menu_num] == self.DICE: return Destination(ToolsDiceEntropyMnemonicLengthView) - elif button_data[selected_menu_num] == KEYBOARD: + elif button_data[selected_menu_num] == self.KEYBOARD: return Destination(ToolsCalcFinalWordNumWordsView) + elif button_data[selected_menu_num] == self.ADDRESS_EXPLORER: + return Destination(ToolsAddressExplorerSelectSourceView) + + elif button_data[selected_menu_num] == self.VERIFY_ADDRESS: + from seedsigner.views.scan_views import ScanAddressView + return Destination(ScanAddressView) + """**************************************************************************** @@ -64,6 +82,7 @@ class ToolsImageEntropyLivePreviewView(View): class ToolsImageEntropyFinalImageView(View): def run(self): if not self.controller.image_entropy_final_image: + from seedsigner.hardware.camera import Camera # Take the final full-res image camera = Camera.get_instance() camera.start_single_frame_mode(resolution=(720, 480)) @@ -126,7 +145,7 @@ class ToolsImageEntropyMnemonicLengthView(View): serial_hash = hashlib.sha256(serial_num) hash_bytes = serial_hash.digest() except Exception as e: - print(repr(e)) + logger.info(repr(e), exc_info=True) hash_bytes = b'0' # Build in modest entropy via millis since power on @@ -170,8 +189,8 @@ class ToolsImageEntropyMnemonicLengthView(View): ****************************************************************************""" class ToolsDiceEntropyMnemonicLengthView(View): def run(self): - TWELVE = "12 words (50 rolls)" - TWENTY_FOUR = "24 words (99 rolls)" + TWELVE = f"12 words ({mnemonic_generation.DICE__NUM_ROLLS__12WORD} rolls)" + TWENTY_FOUR = f"24 words ({mnemonic_generation.DICE__NUM_ROLLS__24WORD} rolls)" button_data = [TWELVE, TWENTY_FOUR] selected_menu_num = ButtonListScreen( @@ -185,10 +204,10 @@ class ToolsDiceEntropyMnemonicLengthView(View): return Destination(BackStackView) elif button_data[selected_menu_num] == TWELVE: - return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=50)) + return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=mnemonic_generation.DICE__NUM_ROLLS__12WORD)) elif button_data[selected_menu_num] == TWENTY_FOUR: - return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=99)) + return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=mnemonic_generation.DICE__NUM_ROLLS__24WORD)) @@ -206,9 +225,7 @@ class ToolsDiceEntropyEntryView(View): if ret == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - print(f"Dice rolls: {ret}") dice_seed_phrase = mnemonic_generation.generate_mnemonic_from_dice(ret) - print(f"""Mnemonic: "{dice_seed_phrase}" """) # Add the mnemonic as an in-memory Seed seed = Seed(dice_seed_phrase, wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)) @@ -223,28 +240,30 @@ class ToolsDiceEntropyEntryView(View): Calc final word Views ****************************************************************************""" class ToolsCalcFinalWordNumWordsView(View): + TWELVE = "12 words" + TWENTY_FOUR = "24 words" + def run(self): - TWELVE = "12 words" - TWENTY_FOUR = "24 words" - - button_data = [TWELVE, TWENTY_FOUR] - selected_menu_num = ButtonListScreen( + button_data = [self.TWELVE, self.TWENTY_FOUR] + + selected_menu_num = self.run_screen( + ButtonListScreen, title="Mnemonic Length", is_bottom_list=True, is_button_text_centered=True, button_data=button_data, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == TWELVE: + elif button_data[selected_menu_num] == self.TWELVE: self.controller.storage.init_pending_mnemonic(12) # return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) - elif button_data[selected_menu_num] == TWENTY_FOUR: + elif button_data[selected_menu_num] == self.TWENTY_FOUR: self.controller.storage.init_pending_mnemonic(24) # return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) @@ -294,8 +313,7 @@ class ToolsCalcFinalWordFinalizePromptView(View): class ToolsCalcFinalWordCoinFlipsView(View): def run(self): - mnemonic = self.controller.storage.pending_mnemonic - mnemonic_length = len(mnemonic) + mnemonic_length = len(self.controller.storage.pending_mnemonic) if mnemonic_length == 12: total_flips = 7 @@ -310,68 +328,74 @@ class ToolsCalcFinalWordCoinFlipsView(View): return Destination(BackStackView) else: - print(ret_val) - binary_string = ret_val + "0" * (11 - total_flips) - wordlist_index = int(binary_string, 2) - wordlist = Seed.get_wordlist(self.controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)) - word = wordlist[wordlist_index] - self.controller.storage.update_pending_mnemonic(word, mnemonic_length - 1) - return Destination(ToolsCalcFinalWordShowFinalWordView, view_args=dict(coin_flips=ret_val)) class ToolsCalcFinalWordShowFinalWordView(View): - def __init__(self, coin_flips=None): + def __init__(self, coin_flips: str = None): super().__init__() - self.coin_flips = coin_flips - - - def run(self): # Construct the actual final word. The user's selected_final_word # contributes: # * 3 bits to a 24-word seed (plus 8-bit checksum) # * 7 bits to a 12-word seed (plus 4-bit checksum) from seedsigner.helpers import mnemonic_generation - mnemonic = self.controller.storage.pending_mnemonic - mnemonic_length = len(mnemonic) wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) wordlist = Seed.get_wordlist(wordlist_language_code) + # Prep the user's selected word / coin flips and the actual final word for + # the display. + if coin_flips: + self.selected_final_word = None + self.selected_final_bits = coin_flips + else: + # Convert the user's final word selection into its binary index equivalent + self.selected_final_word = self.controller.storage.pending_mnemonic[-1] + self.selected_final_bits = format(wordlist.index(self.selected_final_word), '011b') + + if coin_flips: + # fill the last bits (what will eventually be the checksum) with zeros + binary_string = coin_flips + "0" * (11 - len(coin_flips)) + + # retrieve the matching word for the resulting index + wordlist_index = int(binary_string, 2) + wordlist = Seed.get_wordlist(self.controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)) + word = wordlist[wordlist_index] + + # update the pending mnemonic with our new "final" (pre-checksum) word + self.controller.storage.update_pending_mnemonic(word, -1) + + # Now calculate the REAL final word (has a proper checksum) final_mnemonic = mnemonic_generation.calculate_checksum( mnemonic=self.controller.storage.pending_mnemonic, wordlist_language_code=wordlist_language_code, ) - self.controller.storage.update_pending_mnemonic(final_mnemonic[-1], mnemonic_length - 1) - # Prep the user's selected word (if there was one) and the actual final word for - # the display. - if self.coin_flips: - selected_final_word = None - selected_final_bits = self.coin_flips - else: - # Convert the user's final word selection into its binary index equivalent - selected_final_word = mnemonic[-1] - selected_final_bits = format(wordlist.index(selected_final_word), '011b') + # Update our pending mnemonic with the real final word + self.controller.storage.update_pending_mnemonic(final_mnemonic[-1], -1) + + mnemonic = self.controller.storage.pending_mnemonic + mnemonic_length = len(mnemonic) # And grab the actual final word's checksum bits - actual_final_word = self.controller.storage.pending_mnemonic[-1] - if mnemonic_length == 12: - checksum_bits = format(wordlist.index(actual_final_word), '011b')[-4:] - else: - checksum_bits = format(wordlist.index(actual_final_word), '011b')[-8:] + self.actual_final_word = self.controller.storage.pending_mnemonic[-1] + num_checksum_bits = 4 if mnemonic_length == 12 else 8 + self.checksum_bits = format(wordlist.index(self.actual_final_word), '011b')[-num_checksum_bits:] + + def run(self): NEXT = "Next" button_data = [NEXT] - selected_menu_num = ToolsCalcFinalWordScreen( + selected_menu_num = self.run_screen( + ToolsCalcFinalWordScreen, title="Final Word Calc", button_data=button_data, - selected_final_word=selected_final_word, - selected_final_bits=selected_final_bits, - checksum_bits=checksum_bits, - actual_final_word=actual_final_word, - ).display() + selected_final_word=self.selected_final_word, + selected_final_bits=self.selected_final_bits, + checksum_bits=self.checksum_bits, + actual_final_word=self.actual_final_word, + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -409,3 +433,284 @@ class ToolsCalcFinalWordDoneView(View): elif button_data[selected_menu_num] == DISCARD: return Destination(SeedDiscardView) + +"""**************************************************************************** + Address Explorer Views +****************************************************************************""" +class ToolsAddressExplorerSelectSourceView(View): + SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE) + 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): + seeds = self.controller.storage.seeds + button_data = [] + for seed in seeds: + 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, + title="Address Explorer", + button_data=button_data, + is_button_text_centered=False, + is_bottom_list=True, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + # Most of the options require us to go through a side flow(s) before we can + # continue to the address explorer. Set the Controller-level flow so that it + # knows to re-route us once the side flow is complete. + self.controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER + + if len(seeds) > 0 and selected_menu_num < len(seeds): + # User selected one of the n seeds + return Destination( + SeedExportXpubScriptTypeView, + view_args=dict( + seed_num=selected_menu_num, + sig_type=SettingsConstants.SINGLE_SIG, + ) + ) + + elif button_data[selected_menu_num] == self.SCAN_SEED: + from seedsigner.views.scan_views import ScanSeedQRView + return Destination(ScanSeedQRView) + + elif button_data[selected_menu_num] == self.SCAN_DESCRIPTOR: + from seedsigner.views.scan_views import ScanWalletDescriptorView + return Destination(ScanWalletDescriptorView) + + elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: + from seedsigner.views.seed_views import SeedMnemonicEntryView + if button_data[selected_menu_num] == self.TYPE_12WORD: + self.controller.storage.init_pending_mnemonic(num_words=12) + else: + self.controller.storage.init_pending_mnemonic(num_words=24) + return Destination(SeedMnemonicEntryView) + + elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: + from seedsigner.views.seed_views import SeedElectrumMnemonicStartView + return Destination(SeedElectrumMnemonicStartView) + + + +class ToolsAddressExplorerAddressTypeView(View): + RECEIVE = "Receive Addresses" + CHANGE = "Change Addresses" + + + def __init__(self, seed_num: int = None, script_type: str = None, custom_derivation: str = None): + """ + If the explorer source is a seed, `seed_num` and `script_type` must be + specified. `custom_derivation` can be specified as needed. + + If the source is a multisig or single sig wallet descriptor, `seed_num`, + `script_type`, and `custom_derivation` should be `None`. + """ + super().__init__() + self.seed_num = seed_num + self.script_type = script_type + self.custom_derivation = custom_derivation + + network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) + + # Store everything in the Controller's `address_explorer_data` so we don't have + # to keep passing vals around from View to View and recalculating. + data = dict( + seed_num=seed_num, + network=self.settings.get_value(SettingsConstants.SETTING__NETWORK), + embit_network=SettingsConstants.map_network_to_embit(network), + script_type=script_type, + ) + 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(sig_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), + wallet_type=SettingsConstants.SINGLE_SIG, + script_type=self.script_type, + ) + + data["derivation_path"] = derivation_path + data["xpub"] = self.seed.get_xpub(derivation_path, network=network) + + else: + data["wallet_descriptor"] = self.controller.multisig_wallet_descriptor + + self.controller.address_explorer_data = data + + + def run(self): + data = self.controller.address_explorer_data + + wallet_descriptor_display_name = None + if "wallet_descriptor" in data: + wallet_descriptor_display_name = data["wallet_descriptor"].brief_policy.replace(" (sorted)", "") + + script_type = data["script_type"] if "script_type" in data else None + + button_data = [self.RECEIVE, self.CHANGE] + + selected_menu_num = self.run_screen( + ToolsAddressExplorerAddressTypeScreen, + button_data=button_data, + fingerprint=self.seed.get_fingerprint() if self.seed_num is not None else None, + wallet_descriptor_display_name=wallet_descriptor_display_name, + script_type=script_type, + custom_derivation_path=self.custom_derivation, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + # If we entered this flow via an already-loaded seed's SeedOptionsView, we + # need to clear the `resume_main_flow` so that we don't get stuck in a + # SeedOptionsView redirect loop. + # TODO: Refactor to a cleaner `BackStack.get_previous_View_cls()` + if len(self.controller.back_stack) > 1 and self.controller.back_stack[-2].View_cls == SeedOptionsView: + # The BackStack has the current View on the top with the real "back" in second position. + self.controller.resume_main_flow = None + self.controller.address_explorer_data = None + return Destination(BackStackView) + + elif button_data[selected_menu_num] in [self.RECEIVE, self.CHANGE]: + return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=button_data[selected_menu_num] == self.CHANGE)) + + + +class ToolsAddressExplorerAddressListView(View): + def __init__(self, is_change: bool = False, start_index: int = 0, selected_button_index: int = 0, initial_scroll: int = 0): + super().__init__() + self.is_change = is_change + self.start_index = start_index + self.selected_button_index = selected_button_index + self.initial_scroll = initial_scroll + + + def run(self): + self.loading_screen = None + + addresses = [] + button_data = [] + data = self.controller.address_explorer_data + addrs_per_screen = 10 + + addr_storage_key = "receive_addrs" + if self.is_change: + addr_storage_key = "change_addrs" + + if addr_storage_key in data and len(data[addr_storage_key]) >= self.start_index + addrs_per_screen: + # We already calculated this range of addresses; just retrieve them + addresses = data[addr_storage_key][self.start_index:self.start_index + addrs_per_screen] + + else: + try: + from seedsigner.gui.screens.screen import LoadingScreenThread + self.loading_screen = LoadingScreenThread(text="Calculating addrs...") + self.loading_screen.start() + + if addr_storage_key not in data: + data[addr_storage_key] = [] + + if "xpub" in data: + # Single sig explore from seed + if "script_type" in data and data["script_type"] != SettingsConstants.CUSTOM_DERIVATION: + # Standard derivation path + for i in range(self.start_index, self.start_index + addrs_per_screen): + address = embit_utils.get_single_sig_address(xpub=data["xpub"], script_type=data["script_type"], index=i, is_change=self.is_change, embit_network=data["embit_network"]) + addresses.append(address) + data[addr_storage_key].append(address) + else: + # TODO: Custom derivation path + raise Exception("Custom Derivation address explorer not yet implemented") + + elif "wallet_descriptor" in data: + descriptor: Descriptor = data["wallet_descriptor"] + if descriptor.is_basic_multisig: + for i in range(self.start_index, self.start_index + addrs_per_screen): + address = embit_utils.get_multisig_address(descriptor=descriptor, index=i, is_change=self.is_change, embit_network=data["embit_network"]) + addresses.append(address) + data[addr_storage_key].append(address) + + else: + raise Exception("Single sig descriptors not yet supported") + finally: + # Everything is set. Stop the loading screen + self.loading_screen.stop() + + for i, address in enumerate(addresses): + cur_index = i + self.start_index + + # Adjust the trailing addr display length based on available room + # (the index number will push it out on each order of magnitude) + if cur_index < 10: + end_digits = -6 + elif cur_index < 100: + end_digits = -5 + else: + end_digits = -4 + button_data.append(f"{cur_index}:{address[:8]}...{address[end_digits:]}") + + button_data.append(("Next {}".format(addrs_per_screen), None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT)) + + selected_menu_num = self.run_screen( + ButtonListScreen, + title="{} Addrs".format("Receive" if not self.is_change else "Change"), + button_data=button_data, + button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, + button_font_size=GUIConstants.BUTTON_FONT_SIZE + 4, + is_button_text_centered=False, + is_bottom_list=True, + selected_button=self.selected_button_index, + scroll_y_initial_offset=self.initial_scroll, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + if selected_menu_num == len(addresses): + # User clicked NEXT + return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.is_change, start_index=self.start_index + addrs_per_screen)) + + # Preserve the list's current scroll so we can return to the same spot + initial_scroll = self.screen.buttons[0].scroll_y + + index = selected_menu_num + self.start_index + return Destination(ToolsAddressExplorerAddressView, view_args=dict(index=index, address=addresses[selected_menu_num], is_change=self.is_change, start_index=self.start_index, parent_initial_scroll=initial_scroll), skip_current_view=True) + + + +class ToolsAddressExplorerAddressView(View): + def __init__(self, index: int, address: str, is_change: bool, start_index: int, parent_initial_scroll: int = 0): + super().__init__() + self.index = index + self.address = address + self.is_change = is_change + self.start_index = start_index + self.parent_initial_scroll = parent_initial_scroll + + + def run(self): + from seedsigner.gui.screens.screen import QRDisplayScreen + qr_encoder = GenericStaticQrEncoder(data=self.address) + self.run_screen( + QRDisplayScreen, + qr_encoder=qr_encoder, + ) + + # Exiting/Cancelling the QR display screen always returns to the list + return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.is_change, start_index=self.start_index, selected_button_index=self.index - self.start_index, initial_scroll=self.parent_initial_scroll), skip_current_view=True) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 815e5932..a7c83d49 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -1,13 +1,14 @@ -from dataclasses import dataclass -from typing import List +from dataclasses import dataclass, field +from typing import Type -from seedsigner.gui.components import FontAwesomeIconConstants -from seedsigner.gui.screens import RET_CODE__POWER_BUTTON -from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, DireWarningScreen, LargeButtonScreen, PowerOffScreen, ResetScreen, WarningScreen +from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants +from seedsigner.gui.screens import RET_CODE__POWER_BUTTON, RET_CODE__BACK_BUTTON +from seedsigner.gui.screens.screen import BaseScreen, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen +from seedsigner.models.settings import Settings, SettingsConstants +from seedsigner.models.settings_definition import SettingsDefinition from seedsigner.models.threads import BaseThread - class BackStackView: """ Empty class that just signals to the Controller to pop the most recent View off @@ -42,11 +43,15 @@ class BackStackView: "Cancel" - End task and return to entry point (destructive) """ class View: - def __init__(self) -> None: + def _initialize(self): + """ + Whether the View is a regular class initialized by __init__() or a dataclass + initialized by __post_init__(), this method will be called to set up the View's + instance variables. + """ # Import here to avoid circular imports from seedsigner.controller import Controller from seedsigner.gui import Renderer - from seedsigner.models import Settings self.controller: Controller = Controller.get_instance() self.settings = Settings.get_instance() @@ -56,14 +61,57 @@ class View: self.canvas_width = self.renderer.canvas_width self.canvas_height = self.renderer.canvas_height - self.buttons = self.controller.buttons + self.screen = None + + self._redirect: 'Destination' = None - def run(self, **kwargs): - if hasattr(self, "screen"): - self.screen.display() - else: - raise Exception("Must implement in the child class") + def __init__(self): + self._initialize() + + + def __post_init__(self): + self._initialize() + + + @property + def has_redirect(self) -> bool: + if not hasattr(self, '_redirect'): + # Easy for a View to forget to call super().__init__() + raise Exception(f"{self.__class__.__name__} did not call super().__init__()") + return self._redirect is not None + + + def set_redirect(self, destination: 'Destination'): + """ + Enables early `__init__()` / `__post_init__()` logic to redirect away from the + current View. + + Set a redirect Destination and then immediately `return` to exit `__init__()` or + `__post_init__()`. When the `Destination.run()` is called, it will see the redirect + and immediately return that new Destination to the Controller without running + the View's `run()`. + """ + # Always insure skip_current_view is set for a redirect + destination.skip_current_view = True + self._redirect = destination + + + def get_redirect(self) -> 'Destination': + return self._redirect + + + def run_screen(self, Screen_cls: Type[BaseScreen], **kwargs) -> int | str: + """ + Instantiates the provided Screen_cls and runs its interactive display. + Returns the user's input upon completion. + """ + self.screen = Screen_cls(**kwargs) + return self.screen.display() + + + def run(self, **kwargs) -> 'Destination': + raise Exception("Must implement in the child class") @@ -73,10 +121,10 @@ class Destination: Basic struct to pass back to the Controller to tell it which View the user should be presented with next. """ - View_cls: View # The target View to route to - view_args: dict = None # The input args required to instantiate the target View - skip_current_view: bool = False # The current View is just forwarding; omit current View from history - clear_history: bool = False # Optionally clears the back_stack to prevent "back" + View_cls: Type[View] # The target View to route to + view_args: dict = None # The input args required to instantiate the target View + skip_current_view: bool = False # The current View is just forwarding; omit current View from history + clear_history: bool = False # Optionally clears the back_stack to prevent "back" def __repr__(self): @@ -93,12 +141,24 @@ class Destination: return out - def run(self): + def _instantiate_view(self): if not self.view_args: # Can't unpack (**) None so we replace with an empty dict self.view_args = {} - # Instantiate the `View_cls` and run() it with the `view_args` dict - return self.View_cls(**self.view_args).run() + + # Instantiate the `View_cls` with the `view_args` dict + self.view = self.View_cls(**self.view_args) + + + def _run_view(self): + if self.view.has_redirect: + return self.view.get_redirect() + return self.view.run() + + + def run(self): + self._instantiate_view() + return self._run_view() def __eq__(self, obj): @@ -121,53 +181,62 @@ class Destination: # ######################################################################################### class MainMenuView(View): - def run(self): - from .seed_views import SeedsMenuView - from .settings_views import SettingsMenuView - from .scan_views import ScanView - from .tools_views import ToolsMenuView - from seedsigner.gui.screens import LargeButtonScreen - menu_items = [ - (("Scan", FontAwesomeIconConstants.QRCODE), ScanView), - (("Seeds", FontAwesomeIconConstants.KEY), SeedsMenuView), - (("Tools", FontAwesomeIconConstants.SCREWDRIVER_WRENCH), ToolsMenuView), - (("Settings", FontAwesomeIconConstants.GEAR), SettingsMenuView), - ] + SCAN = ("Scan", SeedSignerIconConstants.SCAN) + SEEDS = ("Seeds", SeedSignerIconConstants.SEEDS) + TOOLS = ("Tools", SeedSignerIconConstants.TOOLS) + SETTINGS = ("Settings", SeedSignerIconConstants.SETTINGS) - screen = LargeButtonScreen( + + def run(self): + from seedsigner.gui.screens.screen import MainMenuScreen + button_data = [self.SCAN, self.SEEDS, self.TOOLS, self.SETTINGS] + selected_menu_num = self.run_screen( + MainMenuScreen, title="Home", - title_font_size=26, - button_data=[entry[0] for entry in menu_items], - show_back_button=False, - show_power_button=True, + button_data=button_data, ) - selected_menu_num = screen.display() if selected_menu_num == RET_CODE__POWER_BUTTON: return Destination(PowerOptionsView) - return Destination(menu_items[selected_menu_num][1]) + if button_data[selected_menu_num] == self.SCAN: + from seedsigner.views.scan_views import ScanView + return Destination(ScanView) + + elif button_data[selected_menu_num] == self.SEEDS: + from seedsigner.views.seed_views import SeedsMenuView + return Destination(SeedsMenuView) + + elif button_data[selected_menu_num] == self.TOOLS: + from seedsigner.views.tools_views import ToolsMenuView + return Destination(ToolsMenuView) + + elif button_data[selected_menu_num] == self.SETTINGS: + from seedsigner.views.settings_views import SettingsMenuView + return Destination(SettingsMenuView) class PowerOptionsView(View): + RESET = ("Restart", SeedSignerIconConstants.RESTART) + POWER_OFF = ("Power Off", SeedSignerIconConstants.POWER) + def run(self): - RESET = ("Restart", FontAwesomeIconConstants.ROTATE_RIGHT) - POWER_OFF = ("Power Off", FontAwesomeIconConstants.POWER_OFF) - button_data = [RESET, POWER_OFF] - selected_menu_num = LargeButtonScreen( + button_data = [self.RESET, self.POWER_OFF] + selected_menu_num = self.run_screen( + LargeButtonScreen, title="Reset / Power", show_back_button=True, button_data=button_data - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == RESET: + elif button_data[selected_menu_num] == self.RESET: return Destination(RestartView) - elif button_data[selected_menu_num] == POWER_OFF: + elif button_data[selected_menu_num] == self.POWER_OFF: return Destination(PowerOffView) @@ -176,7 +245,7 @@ class RestartView(View): def run(self): thread = RestartView.DoResetThread() thread.start() - ResetScreen().display() + self.run_screen(ResetScreen) class DoResetThread(BaseThread): @@ -188,18 +257,24 @@ class RestartView(View): # exiting. time.sleep(0.25) - # Kill the SeedSigner process; systemd will automatically restart it. - # `.*` is a wildcard to detect either `python`` or `python3` and with or - # without the `-u` flag. - call("kill $(ps aux | grep '[p]ython.*main.py' | awk '{print $2}')", shell=True) + # Kill the SeedSigner process; Running the process again. + # `.*` is a wildcard to detect either `python`` or `python3`. + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + call("kill $(pidof python*) & python /opt/src/main.py", shell=True) + else: + call("kill $(ps aux | grep '[p]ython.*main.py' | awk '{print $2}')", shell=True) class PowerOffView(View): def run(self): - thread = PowerOffView.PowerOffThread() - thread.start() - PowerOffScreen().display() + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + self.run_screen(PowerOffNotRequiredScreen) + return Destination(BackStackView) + else: + thread = PowerOffView.PowerOffThread() + thread.start() + self.run_screen(PowerOffScreen) class PowerOffThread(BaseThread): @@ -212,35 +287,137 @@ class PowerOffView(View): +@dataclass class NotYetImplementedView(View): + text: str = "This is still on our to-do list!" """ Temporary View to use during dev. """ def run(self): - WarningScreen( + self.run_screen( + WarningScreen, title="Work In Progress", status_headline="Not Yet Implemented", - text="This is still on our to-do list!", + text=self.text, button_data=["Back to Main Menu"], - ).display() + ) return Destination(MainMenuView) -class UnhandledExceptionView(View): - def __init__(self, error: List[str]): - self.error = error +@dataclass +class ErrorView(View): + title: str = "Error" + show_back_button: bool = True + status_headline: str = None + text: str = None + button_text: str = None + next_destination: Destination = field(default_factory=lambda: Destination(MainMenuView, clear_history=True)) def run(self): - DireWarningScreen( + self.run_screen( + WarningScreen, + title=self.title, + status_headline=self.status_headline, + text=self.text, + button_data=[self.button_text], + show_back_button=self.show_back_button, + ) + + return self.next_destination + + + +@dataclass +class NetworkMismatchErrorView(ErrorView): + title: str = "Network Mismatch" + show_back_button: bool = False + button_text: str = "Change Setting" + next_destination: Destination = None + + + def __post_init__(self): + super().__post_init__() + if not self.text: + self.text = f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match current action." + + if not self.next_destination: + from seedsigner.views.settings_views import SettingsEntryUpdateSelectionView + self.next_destination = Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=SettingsConstants.SETTING__NETWORK), clear_history=True) + + + +@dataclass +class UnhandledExceptionView(View): + error: list[str] + + + def run(self): + self.run_screen( + DireWarningScreen, title="System Error", status_headline=self.error[0], text=self.error[1] + "\n" + self.error[2], button_data=["OK"], show_back_button=False, allow_text_overflow=True, # Fit what we can, let the rest go off the edges - ).display() + ) - return Destination(MainMenuView, clear_history=True) \ No newline at end of file + return Destination(MainMenuView, clear_history=True) + + + +@dataclass +class OptionDisabledView(View): + UPDATE_SETTING = "Update Setting" + DONE = "Done" + settings_attr: str + + def __post_init__(self): + super().__post_init__() + self.settings_entry = SettingsDefinition.get_settings_entry(self.settings_attr) + self.error_msg = f"\"{self.settings_entry.display_name}\" is currently disabled in Settings." + + + def run(self): + button_data = [self.UPDATE_SETTING, self.DONE] + selected_menu_num = self.run_screen( + WarningScreen, + title="Option Disabled", + status_headline=None, + text=self.error_msg, + button_data=button_data, + show_back_button=False, + allow_text_overflow=True, # Fit what we can, let the rest go off the edges + ) + + if button_data[selected_menu_num] == self.UPDATE_SETTING: + from seedsigner.views.settings_views import SettingsEntryUpdateSelectionView + return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_attr), clear_history=True) + else: + return Destination(MainMenuView, clear_history=True) + + + +class RemoveMicroSDWarningView(View): + """ + Warning to remove the microsd + """ + def __init__(self, next_view: View): + super().__init__() + self.next_view = next_view + + def run(self): + self.run_screen( + WarningScreen, + title="Security Tip", + status_icon_name=FontAwesomeIconConstants.SDCARD, + status_headline="", + text="For maximum security,\nremove the MicroSD card\nbefore continuing.", + show_back_button=False, + button_data=["Continue"], + ) + + return Destination(self.next_view, clear_history=True) diff --git a/tests/README.md b/tests/README.md index aa79d2a7..12f321d3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,7 +1,13 @@ # Running Tests -You must install the test-runner dependencies: -``` +The tests are designed to be run on non-Raspi hardware. + +On your testing machine you'll have to install: +```bash +# general dependencies +pip3 install -r requirements.txt + +# test suite dependencies pip3 install -r tests/requirements.txt ``` @@ -24,3 +30,33 @@ Run a specific test: ``` pytest tests/test_this_file.py::test_this_specific_test ``` + +Force pytest to show logging output: +```bash +pytest tests/test_this_file.py::test_this_specific_test -o log_cli=1 + +# or (same result) + +pytest tests/test_this_file.py::test_this_specific_test --log-cli-level=DEBUG +``` + +Annoying complications: +* If you want to see `print()` statements that are in a test file, add `-s` +* Better idea: use a proper logger in the test file and use one of the above options to display logs + + +### Test Coverage +Run tests and generate test coverage +``` +coverage run -m pytest +``` + +Show the resulting test coverage details: +``` +coverage report +``` + +Generate the html overview: +``` +coverage html +``` diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 00000000..100ac741 --- /dev/null +++ b/tests/base.py @@ -0,0 +1,289 @@ +import sys +from dataclasses import dataclass +from unittest.mock import MagicMock, Mock, patch +from typing import Callable + +# Prevent importing modules w/Raspi hardware dependencies. +# These must precede any SeedSigner imports. +sys.modules['seedsigner.gui.renderer'] = MagicMock() +sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock() +sys.modules['seedsigner.gui.toast'] = MagicMock() +sys.modules['seedsigner.views.screensaver'] = MagicMock() +sys.modules['seedsigner.hardware.buttons'] = MagicMock() +sys.modules['seedsigner.hardware.camera'] = MagicMock() + +from seedsigner.controller import Controller, FlowBasedTestException, StopFlowBasedTest +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON +from seedsigner.hardware.microsd import MicroSD +from seedsigner.models.settings import Settings +from seedsigner.views.view import Destination, MainMenuView, UnhandledExceptionView, View + +import logging +logger = logging.getLogger(__name__) + + + +class BaseTest: + + class MockMicroSD(Mock): + """ + A test suite-friendly replacement for `MicroSD` that gives a test explicit + control over the reported state of the SD card. + """ + # Tests are free to directly manipulate this attribute as needed (it's reset to + # True before each test in `BaseTest.setup_method()`). + is_inserted: bool = True + + + @classmethod + def setup_class(cls): + # Ensure there are no on-disk artifacts after running tests. + Settings.SETTINGS_FILENAME = "settings-test.json" + + # Mock out the loading screen so it can't spawn. View classes must import locally! + patch('seedsigner.gui.screens.screen.LoadingScreenThread').start() + + # Instantiate the mocked MicroSD; hold on to the instance so tests can manipulate + # it later. + cls.mock_microsd = BaseTest.MockMicroSD() + + # And mock it over `MicroSD`'s instance + MicroSD.get_instance = Mock(return_value=cls.mock_microsd) + + + @classmethod + def teardown_class(cls): + pass + + + @classmethod + def reset_settings(cls): + """ Wipe and re-initialize the Settings singleton """ + Settings._instance = None + BaseTest.remove_settings() + + + @classmethod + def remove_settings(cls): + """ If settings were written to disk, delete """ + import os + try: + if os.path.exists(Settings.SETTINGS_FILENAME): + os.remove(Settings.SETTINGS_FILENAME) + except: + print(f"{Settings.SETTINGS_FILENAME} could not be removed") + + + @classmethod + def reset_controller(cls): + """ Wipe and re-initialize the Controller singleton """ + Controller._instance = None + Controller.configure_instance() + + + def setup_method(self): + """ Guarantee a clean/default Controller, Settings, & MicroSD state for each test case """ + BaseTest.reset_controller() + BaseTest.reset_settings() + self.controller = Controller.get_instance() + self.settings = Settings.get_instance() + self.mock_microsd.is_inserted = True + + + def teardown_method(self): + BaseTest.remove_settings() + + + +class TestBaseTest(BaseTest): + def test_howto_log_from_test(self): + """ + Not actually a test, just a demonstration of how to use/access logs while + testing. + + Enable log visibility by running with: + --log-cli-level=NOTSET (or the level of your choice) + -o log_cli=1 + + Enable print() visibility by running with: + -s or --capture=no + """ + print("This is a test print message") + logger.info("This is a test log message") + logger.debug("This is a test debug message") + logger.warning("This is a test warning message") + logger.error("This is a test error message") + logger.critical("This is a test critical message") + + + +@dataclass +class FlowStep: + """ + Trivial helper class to express FlowTest sequences below. + + * expected_view: verify that the current step in the sequence instantiates the right View. + * before_run: function that takes a View instance as an arg and modifies it before running the View. + * screen_return_value: mocked Screen interaction result: raw return value as if from the Screen. + * button_data_selection: mocked Screen interaction result: the View.button_data value of the desired option. + * is_redirect: expects the Destination to specify `skip_current_view=True`. + """ + expected_view: type[View] = None + before_run: Callable[[View], None] = None + screen_return_value: int | str = None + button_data_selection: str | tuple = None + is_redirect: bool = False + + def __post_init__(self): + if self.screen_return_value is not None and self.button_data_selection is not None: + raise Exception("Can't specify both `screen_return_value` and `button_data_selection`") + + + +class FlowTestInvalidButtonDataSelectionException(FlowBasedTestException): + """ The FlowStep's button_data_selection value was not found in the View's button_data """ + pass + + + +class FlowTestUnexpectedViewException(FlowBasedTestException): + """ The Controller's current View did not match the expected View in the FlowStep sequence """ + pass + + + +class FlowTestUnexpectedRedirectException(FlowBasedTestException): + """ The Controller's current View triggered a redirect that was not expected by the current FlowStep in the sequence """ + pass + + +class FlowTestMissingRedirectException(FlowBasedTestException): + """ The Controller's current View did NOT trigger a redirect when one was expected by the current FlowStep in the sequence """ + pass + + +class FlowTest(BaseTest): + """ Base class for any tests that do flow-based testing """ + + def stop_test(self): + """ Raise our special Exception to stop the test """ + raise StopFlowBasedTest() + + + def run_sequence(self, sequence: list[FlowStep], initial_destination_view_args: dict = None) -> None: + """ + Run a pre-set sequence of Views w/manually-specified return values in order to test + the Controller's flow control logic and the routing from View to View. + """ + with patch("seedsigner.views.view.Destination._run_view", autospec=True) as mock_run_view: + with patch("seedsigner.views.view.View.run_screen", autospec=True) as mock_run_screen: + def run_view(destination: Destination, *args, **kwargs): + """ Replaces Destination._run_view() """ + if len(sequence) == 0: + # Nothing left to do. + self.stop_test() + + cur_flow_step = sequence[0] + + # Verify that the View class specified in the test sequence matches the + # View class that is being run. + if destination.View_cls != cur_flow_step.expected_view: + raise FlowTestUnexpectedViewException(f"Expected {cur_flow_step.expected_view}, got {destination.View_cls}") + + if len(sequence) == 1: + # This is the last step in the sequence + if cur_flow_step.screen_return_value is None and cur_flow_step.button_data_selection is None: + # This is the last View in the sequence and it doesn't specify any + # user-mimicking interactions for the Screen. Nothing left to do. + self.stop_test() + + try: + if cur_flow_step.is_redirect and destination.view.has_redirect: + # Right upon instantiation, the View set its own redirect without + # needing to wait for its run() method to be called. + + # TODO: Migrate all View redirects to use `View.set_redirect()` + # in their `__init__()` rather than `run()` and then refactor + # here to explicitly require `has_redirect` to be True. + # For now: Support the newer `set_redirect()` routing while + # still letting redirects get returned by `View.run()` further + # below. + return destination.view.get_redirect() + + # Run the optional pre-run function to modify the View. + if cur_flow_step.before_run: + cur_flow_step.before_run(destination.view) + + # Some Views reach into their Screen's variables directly (e.g. + # Screen.buttons to preserve the scroll position), so we need to mock out the + # Screen instance that is created by the View. + destination.view.screen = MagicMock() + + prev_mock_run_screen_call_count = mock_run_screen.call_count + + # Run the View (with our mocked run_screen) and get the next Destination that results + destination = destination.view.run() + + if mock_run_screen.call_count == prev_mock_run_screen_call_count and cur_flow_step.is_redirect is not True: + # The current View redirected without calling run_screen() + # but we weren't expecting it. + raise FlowTestUnexpectedRedirectException(f"Unexpected redirect to {destination.View_cls}") + + elif mock_run_screen.call_count > prev_mock_run_screen_call_count and cur_flow_step.is_redirect: + # The View ran its Screen, but the current FlowStep was expecting it + # to redirect (is_redirect=True) *instead of* running its Screen. + raise FlowTestMissingRedirectException(f"FlowStep expected redirect but {cur_flow_step.expected_view} did not redirect") + + finally: + # Regardless of the outcome, we always move our FlowTest + # sequence forward. + sequence.pop(0) + + return destination + + + def run_screen(view: View, *args, **kwargs): + """ + Replaces View.run_screen(). + + Just returns the return value specified in the test sequence. + """ + cur_flow_step = sequence[0] + + if cur_flow_step.button_data_selection: + # We're mocking out the View.run_screen() method, so we'll get all of the + # input args that are normally passed into the Screen.run() method, + # including the button_data kwarg. + if "button_data" in kwargs: + if cur_flow_step.button_data_selection not in kwargs.get("button_data") and cur_flow_step.button_data_selection not in [RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON]: + raise FlowTestInvalidButtonDataSelectionException(f"'{cur_flow_step.button_data_selection}' not found in button_data: {kwargs.get('button_data')}") + return kwargs.get("button_data").index(cur_flow_step.button_data_selection) + else: + raise Exception(f"Can't specify `FlowStep.button_data_selection` if `button_data` isn't a kwarg in {view.__class__.__name__}'s run_screen()") + + elif type(cur_flow_step.screen_return_value) in [StopFlowBasedTest, FlowBasedTestException]: + raise cur_flow_step.screen_return_value + + elif isinstance(cur_flow_step.screen_return_value, Exception): + # The FlowStep wants to mimic the Screen raising an exception. + raise cur_flow_step.screen_return_value + + return cur_flow_step.screen_return_value + + + # Mock out the Destination._run_view() method so we can verify the View class + # that is specified in the test sequence and then run the View. + mock_run_view.side_effect = run_view + + # Mock out the View.run_screen() method so we can provide the + # return value that is specified in the test sequence. + mock_run_screen.side_effect = run_screen + + # Start the Controller with the first View_cls specified in the test sequence + if sequence[0].expected_view != MainMenuView: + initial_destination = Destination(sequence[0].expected_view, view_args=initial_destination_view_args) + else: + initial_destination = None + + # Start the Controller and run the sequence + Controller.get_instance().start(initial_destination=initial_destination) diff --git a/tests/requirements.txt b/tests/requirements.txt index 7f265749..d42adea7 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,2 +1,3 @@ -pytest==6.2.4 -mock==4.0.3 \ No newline at end of file +coverage==7.3.1 +pytest==7.4.2 +pytest-cov==4.1.0 diff --git a/tests/screenshot_generator/README.md b/tests/screenshot_generator/README.md new file mode 100644 index 00000000..f25b163d --- /dev/null +++ b/tests/screenshot_generator/README.md @@ -0,0 +1,8 @@ +# Screenshot Generator + +From the project root, run: +``` +pytest tests/screenshot_generator/generator.py +``` + +Writes the screenshots to a dir in the project root: `seedsigner-screenshots`. diff --git a/tests/screenshot_generator/__init__.py b/tests/screenshot_generator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/screenshot_generator/conftest.py b/tests/screenshot_generator/conftest.py new file mode 100644 index 00000000..43f690b8 --- /dev/null +++ b/tests/screenshot_generator/conftest.py @@ -0,0 +1,11 @@ +import pytest + + + +def pytest_addoption(parser): + parser.addoption("--locale", action="store", default=None) + + +@pytest.fixture(scope='session') +def target_locale(request): + return request.config.option.locale diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py new file mode 100644 index 00000000..4c0c092a --- /dev/null +++ b/tests/screenshot_generator/generator.py @@ -0,0 +1,380 @@ +import embit +import os +import random +import sys +import time +from unittest.mock import Mock, patch, MagicMock +from seedsigner.helpers import embit_utils + +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. +# These must precede any SeedSigner imports. +sys.modules['seedsigner.hardware.ST7789'] = MagicMock() +sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock() +sys.modules['seedsigner.views.screensaver'] = MagicMock() +sys.modules['RPi'] = MagicMock() +sys.modules['RPi.GPIO'] = MagicMock() +sys.modules['seedsigner.hardware.camera'] = MagicMock() +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.microsd import MicroSD +from seedsigner.models.decode_qr import DecodeQR +from seedsigner.models.qr_type import QRType +from seedsigner.models.seed import Seed +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) +from seedsigner.views.view import ErrorView, NetworkMismatchErrorView, OptionDisabledView, PowerOffView, View + +from .utils import ScreenshotComplete, ScreenshotRenderer + + + +def test_generate_screenshots(target_locale): + """ + The `Renderer` class is mocked so that calls in the normal code are ignored + (necessary to avoid having it trying to wire up hardware dependencies). + + When the `Renderer` instance is needed, we patch in our own test-only + `ScreenshotRenderer`. + """ + # Prep the ScreenshotRenderer that will be patched over the normal Renderer + screenshot_root = os.path.join(os.getcwd(), "seedsigner-screenshots") + ScreenshotRenderer.configure_instance() + screenshot_renderer: ScreenshotRenderer = ScreenshotRenderer.get_instance() + + # Replace the core `Singleton` calls so that only our ScreenshotRenderer is used. + Renderer.configure_instance = Mock() + Renderer.get_instance = Mock(return_value=screenshot_renderer) + + # Additional mocks needed + PowerOffView.PowerOffThread = Mock() # Don't let this View actually send the `shutdown` command! + + controller = Controller.get_instance() + + # Set up some test data that we'll need in the `Controller` for certain Views + mnemonic_12 = "forum undo fragile fade shy sign arrest garment culture tube off merit".split() + mnemonic_24 = "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() + mnemonic_12b = ["abandon"] * 11 + ["about"] + seed_12 = Seed(mnemonic=mnemonic_12, passphrase="cap*BRACKET3stove", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + seed_12b = Seed(mnemonic=mnemonic_12b, wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + seed_24 = Seed(mnemonic=mnemonic_24, wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + seed_24_w_passphrase = Seed(mnemonic=mnemonic_24, passphrase="some-PASS*phrase9", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + controller.storage.seeds.append(seed_12) + controller.storage.seeds.append(seed_12b) + controller.storage.seeds.append(seed_24) + controller.storage.set_pending_seed(seed_24_w_passphrase) + UnhandledExceptionViewFood = ["IndexError", "line 1, in some_buggy_code.py", "list index out of range"] + + # Pending mnemonic for ToolsCalcFinalWordShowFinalWordView + controller.storage.init_pending_mnemonic(num_words=12) + for i, word in enumerate(mnemonic_12[:11]): + controller.storage.update_pending_mnemonic(word=word, index=i) + controller.storage.update_pending_mnemonic(word="satoshi", index=11) # random last word; not supposed to be a valid checksum (yet) + + # Load a PSBT into memory + BASE64_PSBT_1 = """cHNidP8BAP06AQIAAAAC5l4E3oEjI+H0im8t/K2nLmF5iJFdKEiuQs8ESveWJKcAAAAAAP3///8iBZMRhYIq4s/LmnTmKBi79M8ITirmsbO++63evK4utwAAAAAA/f///wZYQuoDAAAAACIAIAW5jm3UnC5fyjKCUZ8LTzjENtb/ioRTaBMXeSXsB3n+bK2fCgAAAAAWABReJY7akT1+d+jx475yBRWORdBd7VxbUgUAAAAAFgAU4wj9I/jB3GjNQudNZAca+7g9R16iWtYOAAAAABYAFIotPApLZlfscg8f3ppKqO3qA5nv7BnMFAAAAAAiACAs6SGc8qv4FwuNl0G0SpMZG8ODUEk5RXiWUcuzzw5iaRSfAhMAAAAAIgAgW0f5QxQIgVCGQqKzsvfkXZjUxdFop5sfez6Pt8mUbmZ1AgAAAAEAkgIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BQIRAgEB/////wJAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQErQL5AJQAAAAAiACCET6KNi75K8K4a2BYS4ZT+N4s8WwOBKOmOohRYkGHV0QEFR1EhArGhNdUqlR4BAOLGTMrY2ZJYTQNRudp7fU7i8crRJqgEIQNDxn7PjUzvsP6KYw4s7dmoZE0qO1K6MaM+2ScRZ7hyxFKuIgYCsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQcc8XaCjAAAIABAACAAAAAgAIAAIAAAAAAAwAAACIGA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEHCK94akwAACAAQAAgAAAAIACAACAAAAAAAMAAAAAAQCSAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8FAhACAQH/////AkC+QCUAAAAAIgAghE+ijYu+SvCuGtgWEuGU/jeLPFsDgSjpjqIUWJBh1dEAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABAStAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAQVHUSECsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQhA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEUq4iBgKxoTXVKpUeAQDixkzK2NmSWE0DUbnae31O4vHK0SaoBBxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAADAAAAIgYDQ8Z+z41M77D+imMOLO3ZqGRNKjtSujGjPtknEWe4csQcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAAAwAAAAABAUdRIQJ5XLCBS0hdo4NANq4lNhimzhyHj7dvObmPAwNj8L2xASEC9mwwoH28/WHnxbb6z05sJ/lHuvrLs/wOooHgFn5ulI1SriICAnlcsIFLSF2jg0A2riU2GKbOHIePt285uY8DA2PwvbEBHCK94akwAACAAQAAgAAAAIACAACAAQAAAAEAAAAiAgL2bDCgfbz9YefFtvrPTmwn+Ue6+suz/A6igeAWfm6UjRxzxdoKMAAAgAEAAIAAAACAAgAAgAEAAAABAAAAAAAAAAEBR1EhAgpbWcEh7rgvRE5UaCcqzWL/TR1B/DS8UeZsKVEvuKLrIQOwLg0emiQbbxafIh69Xjtpj4eclsMhKq1y/7vYDdE7LVKuIgICCltZwSHuuC9ETlRoJyrNYv9NHUH8NLxR5mwpUS+4ouscc8XaCjAAAIABAACAAAAAgAIAAIAAAAAABQAAACICA7AuDR6aJBtvFp8iHr1eO2mPh5yWwyEqrXL/u9gN0TstHCK94akwAACAAQAAgAAAAIACAACAAAAAAAUAAAAAAQFHUSECk50GLh/YhZaLJkDq/dugU3H/WvE6rTgQuY6N57pI4ykhA/H8MdLVP9SA/Hg8l3hvibSaC1bCBzwz7kTW+rsEZ8uFUq4iAgKTnQYuH9iFlosmQOr926BTcf9a8TqtOBC5jo3nukjjKRxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAAGAAAAIgID8fwx0tU/1ID8eDyXeG+JtJoLVsIHPDPuRNb6uwRny4UcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAABgAAAAA=""" + decoder = DecodeQR() + decoder.add_data(BASE64_PSBT_1) + 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) + + # Message signing data + derivation_path = "m/84h/0h/0h/0/0" + controller.sign_message_data = { + "seed_num": 0, + "derivation_path": derivation_path, + "message": "I attest that I control this bitcoin address blah blah blah", + "addr_format": embit_utils.parse_derivation_path(derivation_path) + } + + # Automatically populate all Settings options Views + settings_views_list = [] + settings_views_list.append(settings_views.SettingsMenuView) + settings_views_list.append(( + settings_views.SettingsMenuView, + dict( + visibility=SettingsConstants.VISIBILITY__ADVANCED, + selected_attr=SettingsConstants.SETTING__ELECTRUM_SEEDS, + initial_scroll=240, # Just guessing how many pixels to scroll down + ), + "SettingsMenuView__Advanced" + )) + + # so we get a choice for transcribe seed qr format + controller.settings.set_value( + attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR, + value=SettingsConstants.OPTION__ENABLED + ) + for settings_entry in SettingsDefinition.settings_entries: + if settings_entry.visibility == SettingsConstants.VISIBILITY__HIDDEN: + continue + + settings_views_list.append((settings_views.SettingsEntryUpdateSelectionView, dict(attr_name=settings_entry.attr_name), f"SettingsEntryUpdateSelectionView_{settings_entry.attr_name}")) + + + settingsqr_data_persistent = "settings::v1 name=Total_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E" + settingsqr_data_not_persistent = "settings::v1 name=Ephemeral_noob_mode persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E" + + screenshot_sections = { + "Main Menu Views": [ + MainMenuView, + (MainMenuView, {}, 'MainMenuView_SDCardStateChangeToast_removed', SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__REMOVED)), + (MainMenuView, {}, 'MainMenuView_SDCardStateChangeToast_inserted', SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__INSERTED)), + (MainMenuView, {}, 'MainMenuView_RemoveSDCardToast', RemoveSDCardToastManagerThread(activation_delay=0)), + PowerOptionsView, + RestartView, + PowerOffView, + ], + "Seed Views": [ + seed_views.SeedsMenuView, + seed_views.LoadSeedView, + seed_views.SeedMnemonicEntryView, + seed_views.SeedMnemonicInvalidView, + seed_views.SeedFinalizeView, + seed_views.SeedAddPassphraseView, + seed_views.SeedAddPassphraseExitDialogView, + seed_views.SeedReviewPassphraseView, + + (seed_views.SeedOptionsView, dict(seed_num=0)), + (seed_views.SeedBackupView, dict(seed_num=0)), + (seed_views.SeedExportXpubSigTypeView, dict(seed_num=0)), + (seed_views.SeedExportXpubScriptTypeView, dict(seed_num=0, sig_type="msig")), + (seed_views.SeedExportXpubCustomDerivationView, dict(seed_num=0, sig_type="ss", script_type="")), + (seed_views.SeedExportXpubCoordinatorView, dict(seed_num=0, sig_type="ss", script_type="nat")), + (seed_views.SeedExportXpubWarningView, dict(seed_num=0, sig_type="msig", script_type="nes", coordinator="spd", custom_derivation="")), + (seed_views.SeedExportXpubDetailsView, dict(seed_num=0, sig_type="ss", script_type="nat", coordinator="bw", custom_derivation="")), + #SeedExportXpubQRDisplayView, + (seed_views.SeedWordsWarningView, dict(seed_num=0)), + (seed_views.SeedWordsView, dict(seed_num=0)), + (seed_views.SeedWordsView, dict(seed_num=0, page_index=2), "SeedWordsView_2"), + (seed_views.SeedBIP85ApplicationModeView, dict(seed_num=0)), + (seed_views.SeedBIP85SelectChildIndexView, dict(seed_num=0, num_words=24)), + (seed_views.SeedBIP85InvalidChildIndexView, dict(seed_num=0, num_words=12)), + (seed_views.SeedWordsBackupTestPromptView, dict(seed_num=0)), + (seed_views.SeedWordsBackupTestView, dict(seed_num=0)), + (seed_views.SeedWordsBackupTestMistakeView, dict(seed_num=0, cur_index=7, wrong_word="unlucky")), + (seed_views.SeedWordsBackupTestSuccessView, dict(seed_num=0)), + (seed_views.SeedTranscribeSeedQRFormatView, dict(seed_num=0)), + (seed_views.SeedTranscribeSeedQRWarningView, dict(seed_num=0)), + (seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=21), "SeedTranscribeSeedQRWholeQRView_12_Compact"), + (seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, num_modules=25), "SeedTranscribeSeedQRWholeQRView_12_Standard"), + (seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=25), "SeedTranscribeSeedQRWholeQRView_24_Compact"), + (seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__SEEDQR, num_modules=29), "SeedTranscribeSeedQRWholeQRView_24_Standard"), + + # Screenshot doesn't render properly due to how the transparency mask is pre-rendered + # (seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR)), + + (seed_views.SeedTranscribeSeedQRConfirmQRPromptView, dict(seed_num=0)), + + # Screenshot can't render live preview screens + # (seed_views.SeedTranscribeSeedQRConfirmScanView, dict(seed_num=0)), + + #(seed_views.AddressVerificationStartView, dict(address=, script_type="nat", network="M")), + #seed_views.AddressVerificationSigTypeView, + #seed_views.SeedSingleSigAddressVerificationSelectSeedView, + #seed_views.SeedAddressVerificationView, + #seed_views.AddressVerificationSuccessView, + + seed_views.LoadMultisigWalletDescriptorView, + seed_views.MultisigWalletDescriptorView, + (seed_views.SeedDiscardView, dict(seed_num=0)), + + seed_views.SeedSignMessageConfirmMessageView, + seed_views.SeedSignMessageConfirmAddressView, + + seed_views.SeedElectrumMnemonicStartView, + ], + "PSBT Views": [ + psbt_views.PSBTSelectSeedView, # this will fail, be rerun below + psbt_views.PSBTOverviewView, + psbt_views.PSBTUnsupportedScriptTypeWarningView, + psbt_views.PSBTNoChangeWarningView, + psbt_views.PSBTMathView, + (psbt_views.PSBTAddressDetailsView, dict(address_num=0)), + + (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"), + (psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=True), "PSBTAddressVerificationFailedView_multisig_selftransfer"), + psbt_views.PSBTFinalizeView, + #PSBTSignedQRDisplayView + psbt_views.PSBTSigningErrorView, + ], + "Tools Views": [ + tools_views.ToolsMenuView, + #ToolsImageEntropyLivePreviewView + #ToolsImageEntropyFinalImageView + tools_views.ToolsImageEntropyMnemonicLengthView, + tools_views.ToolsDiceEntropyMnemonicLengthView, + (tools_views.ToolsDiceEntropyEntryView, dict(total_rolls=50)), + tools_views.ToolsCalcFinalWordNumWordsView, + tools_views.ToolsCalcFinalWordFinalizePromptView, + tools_views.ToolsCalcFinalWordCoinFlipsView, + (tools_views.ToolsCalcFinalWordShowFinalWordView, {}, "ToolsCalcFinalWordShowFinalWordView_pick_word"), + (tools_views.ToolsCalcFinalWordShowFinalWordView, dict(coin_flips="0010101"), "ToolsCalcFinalWordShowFinalWordView_coin_flips"), + #tools_views.ToolsCalcFinalWordDoneView, + tools_views.ToolsAddressExplorerSelectSourceView, + tools_views.ToolsAddressExplorerAddressTypeView, + tools_views.ToolsAddressExplorerAddressListView, + #tools_views.ToolsAddressExplorerAddressView, + ], + "Settings Views": settings_views_list + [ + settings_views.IOTestView, + settings_views.DonateView, + (settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), "SettingsIngestSettingsQRView_persistent"), + (settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), "SettingsIngestSettingsQRView_not_persistent"), + ], + "Misc Error Views": [ + NotYetImplementedView, + (UnhandledExceptionView, dict(error=UnhandledExceptionViewFood)), + NetworkMismatchErrorView, + (OptionDisabledView, dict(settings_attr=SettingsConstants.SETTING__MESSAGE_SIGNING)), + (ErrorView, dict( + title="Error", + status_headline="Unknown QR Type", + text="QRCode is invalid or is a data format not yet supported.", + button_text="Back", + )), + ] + } + + readme = f"""# SeedSigner Screenshots\n""" + + 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}") + try: + view_cls(**view_args).run() + except ScreenshotComplete: + if toast_thread is not None: + controller.activate_toast(toast_thread) + while controller.toast_notification_thread.is_alive(): + time.sleep(0.1) + raise ScreenshotComplete() + except ScreenshotComplete: + # Slightly hacky way to exit ScreenshotRenderer as expected + pass + print(f"Completed {view_name}") + except Exception as e: + # Something else went wrong + from traceback import print_exc + print_exc() + raise e + finally: + 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)) + readme += "\n\n---\n\n" + readme += f"## {section_name}\n\n" + readme += """""" + readme += f"""
\n""" + for screenshot in screenshot_list: + if type(screenshot) == tuple: + if len(screenshot) == 2: + view_cls, view_args = screenshot + view_name = view_cls.__name__ + elif len(screenshot) == 3: + view_cls, view_args, view_name = screenshot + elif len(screenshot) == 4: + view_cls, view_args, view_name, toast_thread = screenshot + else: + view_cls = screenshot + view_args = {} + view_name = view_cls.__name__ + toast_thread = None + + screencap_view(view_cls, view_args=view_args, view_name=view_name, toast_thread=toast_thread) + readme += """ """ + readme += f"""""" + readme += """
{view_name}

\n""" + + readme += "
" + + # 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, view_name='PSBTChangeDetailsView_multisig_unverified', view_args=dict(change_address_num=0)) + + controller.psbt_seed = None + 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/screenshot_generator/utils.py b/tests/screenshot_generator/utils.py new file mode 100644 index 00000000..703bbf61 --- /dev/null +++ b/tests/screenshot_generator/utils.py @@ -0,0 +1,55 @@ +import os +from PIL import Image, ImageDraw +from seedsigner.gui.renderer import Renderer + + + +class ScreenshotComplete(Exception): + pass + + + +class ScreenshotRenderer(Renderer): + screenshot_path: str = None + screenshot_filename: str = None + + @classmethod + def configure_instance(cls): + # Instantiate the one and only Renderer instance + renderer = cls.__new__(cls) + cls._instance = renderer + + # Hard-coding output values for now + renderer.canvas_width = 240 + renderer.canvas_height = 240 + + renderer.canvas = Image.new('RGB', (renderer.canvas_width, renderer.canvas_height)) + renderer.draw = ImageDraw.Draw(renderer.canvas) + + + def set_screenshot_filename(self, filename:str): + self.screenshot_filename = filename + + + def set_screenshot_path(self, path): + if not os.path.exists(path): + os.makedirs(path) + self.screenshot_path = path + + + def show_image(self, image=None, alpha_overlay=None, is_background_thread: bool = False): + if is_background_thread: + return + + if alpha_overlay: + if image == None: + image = self.canvas + image = Image.alpha_composite(image, alpha_overlay) + + if image: + # Always write to the current canvas, rather than trying to replace it + self.canvas.paste(image) + + self.canvas.save(os.path.join(self.screenshot_path, self.screenshot_filename)) + raise ScreenshotComplete() + diff --git a/tests/test_bip85.py b/tests/test_bip85.py new file mode 100644 index 00000000..737be569 --- /dev/null +++ b/tests/test_bip85.py @@ -0,0 +1,23 @@ +import pytest +from unittest.mock import MagicMock +from seedsigner.models.seed import Seed +from embit import bip39 + +from seedsigner.models.settings import SettingsConstants + + + +def test_derive_child_mnemonic(): + + expected = "unusual topic foot figure pulp target glimpse core electric spot neglect fame" + seed = Seed(mnemonic="resource timber firm banner horror pupil frozen main pear direct pioneer broken grid core insane begin sister pony end debate task silk empty curious".split()) + + actual = seed.get_bip85_child_mnemonic(0, 12) + assert actual == expected + + + expected = "imitate post very mandate retreat prevent tiny snow fetch canvas town shrug fix food summer library symptom occur slam style cruise wolf phone key" + seed = Seed(mnemonic="resource timber firm banner horror pupil frozen main pear direct pioneer broken grid core insane begin sister pony end debate task silk empty curious".split()) + + actual = seed.get_bip85_child_mnemonic(0, 24) + assert actual == expected diff --git a/tests/test_controller.py b/tests/test_controller.py index b6887369..375ae9ff 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,53 +1,116 @@ -import configparser import pytest -from mock import MagicMock + +# Must import this before the Controller +from base import BaseTest + from seedsigner.controller import Controller -from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants +class TestController(BaseTest): -def test_singleton_init_fails(): - """ The Controller should not allow any code to instantiate it via Controller() """ - with pytest.raises(Exception): - c = Controller() + def test_reset_controller(self): + """ The reset_controller util should completely reset the Controller singleton """ + controller = Controller.get_instance() + controller.address_explorer_data = "foo" + + BaseTest.reset_controller() + controller = Controller.get_instance() + assert controller.address_explorer_data is None -def test_singleton_get_instance_preserves_state(): - """ Changes to the Controller singleton should be preserved across calls to get_instance() """ - - # Must reset Singleton instances; pytest cannot properly isolate Singletons for us - # automatically. - # TODO: Cleaner solution here would be nice. - Settings._instance = None - Controller._instance = None - - # Initialize the instance and verify that it read the config settings - Controller.configure_instance(disable_hardware=True) - controller = Controller.get_instance() - assert controller.unverified_address is None - - # Change a value in the instance... - controller.unverified_address = "123abc" - - # ...get a new copy of the instance and confirm change - controller = Controller.get_instance() - assert controller.unverified_address == "123abc" + def test_singleton_init_fails(self): + """ The Controller should not allow any code to instantiate it via Controller() """ + with pytest.raises(Exception): + c = Controller() -def test_missing_settings_get_defaults(): - """ Should gracefully handle any missing fields from `settings.ini` """ - # TODO: This is not complete; currently only handles missing compact_seedqr_enabled. + def test_handle_exception(reset_controller): + """ Handle exceptions that get caught by the controller """ - # Must reset Singleton instances; pytest cannot properly isolate Singletons for us - # automatically. - # TODO: Cleaner solution here would be nice. - Settings._instance = None - Controller._instance = None + def process_exception_asserting_valid_error(exception_type, exception_msg=None): + """ + Exceptions caught by the controller are forwarded to the + UnhandledExceptionView with view_args["error"] being a list + of three strings, ie: [exception_type, line_info, exception_msg] + """ + try: + if exception_msg: + raise exception_type(exception_msg) + else: + raise exception_type() + except Exception as e: + error = controller.handle_exception(e).view_args["error"] - # Controller should parse the settings fine, even though a field is missing - Controller.configure_instance(disable_hardware=True) + # assert that error structure is valid + assert len(error) == 3 + assert error[0] in str(exception_type) + assert type(error[1]) == str + if exception_msg: + assert exception_msg in error[2] + else: + assert error[2] == "" + + # Initialize the controller + controller = Controller.get_instance() + + exception_tests = [ + # exceptions with an exception_msg + (Exception, "foo"), + (KeyError, "key not found"), + # exceptions without an exception_msg + (Exception, ""), + (Exception, None), + ] + + for exception_type, exception_msg in exception_tests: + process_exception_asserting_valid_error(exception_type, exception_msg) + + + def test_singleton_get_instance_preserves_state(self): + """ Changes to the Controller singleton should be preserved across calls to get_instance() """ + + # Initialize the instance and verify that it read the config settings + controller = Controller.get_instance() + assert controller.unverified_address is None + + # Change a value in the instance... + controller.unverified_address = "123abc" + + # ...get a new copy of the instance and confirm change + controller = Controller.get_instance() + assert controller.unverified_address == "123abc" + + + def test_missing_settings_get_defaults(self): + """ Should gracefully handle all missing fields from `settings.json` """ + + controller = Controller.get_instance() + + # Settings defaults + assert controller.settings.get_value(SettingsConstants.SETTING__LANGUAGE) == SettingsConstants.LANGUAGE__ENGLISH + assert controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH + assert controller.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED + assert controller.settings.get_value(SettingsConstants.SETTING__COORDINATORS) == [i for i,j in SettingsConstants.ALL_COORDINATORS if i!="kpr"] + assert controller.settings.get_value(SettingsConstants.SETTING__BTC_DENOMINATION) == SettingsConstants.BTC_DENOMINATION__THRESHOLD + + # Advanced Settings defaults + assert controller.settings.get_value(SettingsConstants.SETTING__NETWORK) == SettingsConstants.MAINNET + assert controller.settings.get_value(SettingsConstants.SETTING__QR_DENSITY) == SettingsConstants.DENSITY__MEDIUM + assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__SIG_TYPES) == [i for i,j in SettingsConstants.ALL_SIG_TYPES] + assert controller.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES) == [SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT, SettingsConstants.TAPROOT] + assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_DETAILS) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__CAMERA_ROTATION) == SettingsConstants.CAMERA_ROTATION__180 + assert controller.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__DISABLED + assert controller.settings.get_value(SettingsConstants.SETTING__MESSAGE_SIGNING) == SettingsConstants.OPTION__DISABLED + assert controller.settings.get_value(SettingsConstants.SETTING__PRIVACY_WARNINGS) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS_TIPS) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED + + # Hidden Settings defaults + assert controller.settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS) == 62 - # Controller should still have a default value - controller = Controller.get_instance() - assert controller.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__DISABLED diff --git a/tests/test_decodepsbtqr.py b/tests/test_decodepsbtqr.py index ef9baa4a..9b7d6981 100644 --- a/tests/test_decodepsbtqr.py +++ b/tests/test_decodepsbtqr.py @@ -1,7 +1,7 @@ -import pytest -from mock import MagicMock -from seedsigner.models import Seed, DecodeQR, DecodeQRStatus, QRType, PSBTParser -from embit import psbt, bip39 +from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus +from seedsigner.models.psbt_parser import PSBTParser +from seedsigner.models.qr_type import QRType +from seedsigner.models.seed import Seed from seedsigner.models.settings_definition import SettingsConstants diff --git a/tests/test_embit_utils.py b/tests/test_embit_utils.py new file mode 100644 index 00000000..52f37b75 --- /dev/null +++ b/tests/test_embit_utils.py @@ -0,0 +1,428 @@ +import pytest + +from seedsigner.models.settings_definition import SettingsConstants as SC +from seedsigner.helpers import embit_utils + + +def test_get_standard_derivation_path(): + """ + tests seedsigner.helpers.embit_utils.get_standard_derivation_path() + """ + + vectors_args_expected = { + # single sig + tuple(): "m/84'/0'/0'", + (SC.MAINNET,): "m/84'/0'/0'", + (SC.MAINNET, SC.SINGLE_SIG, ): "m/84'/0'/0'", + + (SC.MAINNET, SC.SINGLE_SIG, SC.NATIVE_SEGWIT): "m/84'/0'/0'", + (SC.TESTNET, SC.SINGLE_SIG, SC.NATIVE_SEGWIT): "m/84'/1'/0'", + (SC.REGTEST, SC.SINGLE_SIG, SC.NATIVE_SEGWIT): "m/84'/1'/0'", + + (SC.MAINNET, SC.SINGLE_SIG, SC.NESTED_SEGWIT): "m/49'/0'/0'", + (SC.TESTNET, SC.SINGLE_SIG, SC.NESTED_SEGWIT): "m/49'/1'/0'", + (SC.REGTEST, SC.SINGLE_SIG, SC.NESTED_SEGWIT): "m/49'/1'/0'", + + (SC.MAINNET, SC.SINGLE_SIG, SC.TAPROOT): "m/86'/0'/0'", + (SC.TESTNET, SC.SINGLE_SIG, SC.TAPROOT): "m/86'/1'/0'", + (SC.REGTEST, SC.SINGLE_SIG, SC.TAPROOT): "m/86'/1'/0'", + + (SC.MAINNET, SC.SINGLE_SIG, SC.LEGACY_P2PKH): "m/44'/0'/0'", + (SC.TESTNET, SC.SINGLE_SIG, SC.LEGACY_P2PKH): "m/44'/1'/0'", + (SC.REGTEST, SC.SINGLE_SIG, SC.LEGACY_P2PKH): "m/44'/1'/0'", + + + # multi sig + (SC.MAINNET, SC.MULTISIG, SC.NATIVE_SEGWIT): "m/48'/0'/0'/2'", + (SC.TESTNET, SC.MULTISIG, SC.NATIVE_SEGWIT): "m/48'/1'/0'/2'", + (SC.REGTEST, SC.MULTISIG, SC.NATIVE_SEGWIT): "m/48'/1'/0'/2'", + + (SC.MAINNET, SC.MULTISIG, SC.NESTED_SEGWIT): "m/48'/0'/0'/1'", + (SC.TESTNET, SC.MULTISIG, SC.NESTED_SEGWIT): "m/48'/1'/0'/1'", + (SC.REGTEST, SC.MULTISIG, SC.NESTED_SEGWIT): "m/48'/1'/0'/1'", + + (SC.MAINNET, SC.MULTISIG, SC.TAPROOT): Exception, + (SC.TESTNET, SC.MULTISIG, SC.TAPROOT): Exception, + (SC.REGTEST, SC.MULTISIG, SC.TAPROOT): Exception, + + (SC.MAINNET, SC.MULTISIG, SC.LEGACY_P2PKH): "m/45'", + + # intentionally fall into exceptions + (SC.MAINNET, SC.SINGLE_SIG, 'invalid'): Exception, + (SC.MAINNET, SC.MULTISIG, 'invalid'): Exception, + (SC.MAINNET, 'invalid', SC.NATIVE_SEGWIT): Exception, + + # nonsense arguments + ("A",): Exception, + ("B", "A"): Exception, + ("C", "B", "A"): Exception, + (True,): Exception, + (False, True): Exception, + (tuple(),): Exception, + } + func = embit_utils.get_standard_derivation_path + + print() + for args, expected in vectors_args_expected.items(): + + # test successful calls + if type(expected) is str: + # call with ordered params + print(f"asserting {func.__name__}(*{args}) == {repr(expected)}") + assert func(*args) == expected + + # call with named params + a_dict = {} + if len(args) == 1: a_dict = {'network': args[0]} + elif len(args) == 2: a_dict = {'network': args[0], 'wallet_type': args[1]} + elif len(args) == 3: a_dict = {'network': args[0], 'wallet_type': args[1], 'script_type': args[2]} + print(f"asserting {func.__name__}(**{a_dict}) == {repr(expected)}") + assert func(**a_dict) == expected + + # test exceptions + else: + # call with ordered params + with pytest.raises(expected): + print(f"asserting {func.__name__}(*{args}) raises Exception") + func(*args) + + # call with named params + a_dict = {} + if len(args) == 1: a_dict = {'network': args[0]} + elif len(args) == 2: a_dict = {'network': args[0], 'wallet_type': args[1]} + elif len(args) == 3: a_dict = {'network': args[0], 'wallet_type': args[1], 'script_type': args[2]} + print(f"asserting {func.__name__}(**{a_dict}) raises Exception") + with pytest.raises(expected): + func(**a_dict) + + +def test_get_xpub(): + """ + tests seedsigner.helpers.embit_utils.get_xpub() + """ + + from binascii import unhexlify + from embit import bip39, bip32 + + # test vectors originate from: + # https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki + # https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md + # https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki + # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki + vector_seeds = ( + unhexlify("000102030405060708090a0b0c0d0e0f"), + unhexlify("fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542"), + unhexlify("4b381541583be4423346c643850da4b320e46a87ae3d2a4e6da11eba819cd4acba45d239319ac14f863b8d5ab5a0d0c64d2e8a1e7d1457df2e5a3c51c73235be"), + unhexlify("3ddd5602285899a946114506157c7997e5444528f3003f6134712147db19b678"), + bip39.mnemonic_to_seed("abandon "*11 + "about"), + ) + + vectors_args_expected = { + # https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vector-1 + (vector_seeds[0], "m/", "main"): "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8", + (vector_seeds[0], "m/0'", "main"): "xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw", + (vector_seeds[0], "m/0h/1", "main"): "xpub6ASuArnXKPbfEwhqN6e3mwBcDTgzisQN1wXN9BJcM47sSikHjJf3UFHKkNAWbWMiGj7Wf5uMash7SyYq527Hqck2AxYysAA7xmALppuCkwQ", + (vector_seeds[0], "m/0'/1/2h", "main"): "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5", + (vector_seeds[0], "m/0'/1/2h/2", "main"): "xpub6FHa3pjLCk84BayeJxFW2SP4XRrFd1JYnxeLeU8EqN3vDfZmbqBqaGJAyiLjTAwm6ZLRQUMv1ZACTj37sR62cfN7fe5JnJ7dh8zL4fiyLHV", + (vector_seeds[0], "m/0'/1/2h/2/1000000000", "main"): "xpub6H1LXWLaKsWFhvm6RVpEL9P4KfRZSW7abD2ttkWP3SSQvnyA8FSVqNTEcYFgJS2UaFcxupHiYkro49S8yGasTvXEYBVPamhGW6cFJodrTHy", + + # https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vector-2 + (vector_seeds[1], "m/", "main"): "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB", + (vector_seeds[1], "m/0", "main"): "xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH", + (vector_seeds[1], "m/0/2147483647'", "main"): "xpub6ASAVgeehLbnwdqV6UKMHVzgqAG8Gr6riv3Fxxpj8ksbH9ebxaEyBLZ85ySDhKiLDBrQSARLq1uNRts8RuJiHjaDMBU4Zn9h8LZNnBC5y4a", + (vector_seeds[1], "m/0/2147483647h/1", "main"): "xpub6DF8uhdarytz3FWdA8TvFSvvAh8dP3283MY7p2V4SeE2wyWmG5mg5EwVvmdMVCQcoNJxGoWaU9DCWh89LojfZ537wTfunKau47EL2dhHKon", + (vector_seeds[1], "m/0/2147483647'/1/2147483646h", "main"): "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL", + (vector_seeds[1], "m/0/2147483647h/1/2147483646'/2", "main"): "xpub6FnCn6nSzZAw5Tw7cgR9bi15UV96gLZhjDstkXXxvCLsUXBGXPdSnLFbdpq8p9HmGsApME5hQTZ3emM2rnY5agb9rXpVGyy3bdW6EEgAtqt", + + # https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vector-3 + (vector_seeds[2], "m/", "main"): "xpub661MyMwAqRbcEZVB4dScxMAdx6d4nFc9nvyvH3v4gJL378CSRZiYmhRoP7mBy6gSPSCYk6SzXPTf3ND1cZAceL7SfJ1Z3GC8vBgp2epUt13", + (vector_seeds[2], "m/0h", "main"): "xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y", + + # https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vector-4 + (vector_seeds[3], "m/", "main"): "xpub661MyMwAqRbcGczjuMoRm6dXaLDEhW1u34gKenbeYqAix21mdUKJyuyu5F1rzYGVxyL6tmgBUAEPrEz92mBXjByMRiJdba9wpnN37RLLAXa", + (vector_seeds[3], "m/0'", "main"): "xpub69AUMk3qDBi3uW1sXgjCmVjJ2G6WQoYSnNHyzkmdCHEhSZ4tBok37xfFEqHd2AddP56Tqp4o56AePAgCjYdvpW2PU2jbUPFKsav5ut6Ch1m", + (vector_seeds[3], "m/0h/1'", "main"): "xpub6BJA1jSqiukeaesWfxe6sNK9CCGaujFFSJLomWHprUL9DePQ4JDkM5d88n49sMGJxrhpjazuXYWdMf17C9T5XnxkopaeS7jGk1GyyVziaMt", + + # + # embit_utils.get_xpub() returns the generic bip32 version "xpub", NOT the zpub/Zpub/ypub/Ypub extended versions + # + + # https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki#test-vectors + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors + (vector_seeds[4], "m/84'/0'/0'", "main"): + bip32.HDKey.from_string("zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs").to_base58(version=b'\x04\x88\xb2\x1e'), + + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors + (vector_seeds[4], "m/49'/0'/0'", "main"): + bip32.HDKey.from_string("ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP").to_base58(version=b'\x04\x88\xb2\x1e'), + + # https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki#test-vectors + (vector_seeds[4], "m/49'/1'/0'", "test"): + bip32.HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY").to_base58(version=b'\x04\x35\x87\xcf'), + + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors + (vector_seeds[4], "m/44'/0'/0'", "main"): + bip32.HDKey.from_string("xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj").to_base58(version=b'\x04\x88\xb2\x1e'), + + # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors + (vector_seeds[4], "m/86'/0'/0'", "main"): "xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ", + + } + func = embit_utils.get_xpub + + print() + for args, expected in vectors_args_expected.items(): + print("\nasserting...") + + # call without optional params (default is "main") + if args[2] == "main": + print(f' {func.__name__}({args[0]}, "{args[1]}") == "{expected}"') + assert str(func(args[0], args[1])) == expected + + # call with ordered params + print(f' {func.__name__}(*{args}) == "{expected}"') + assert str(func(*args)) == expected + + # call with named params + print(f' {func.__name__}(seed_bytes={args[0]}, derivation_path="{args[1]}", embit_network="{args[2]}") == "{expected}"') + assert str(func(seed_bytes=args[0], derivation_path=args[1], embit_network=args[2])) == expected + + +def test_get_single_sig_address(): + """ + tests seedsigner.helpers.embit_utils.get_single_sig_address() + """ + + from embit.bip32 import HDKey + + # test vectors originate from: + # https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md + # https://iancoleman.io/bip39/ + # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki + + vectors_args_expected = { + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors (first payment address of native segwit on mainnet) + (HDKey.from_string("zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"), "nat", 0, False, "main"): + "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", + # jdlcdl: derived via iancoleman test vector for first change address of native segwit on mainnet + (HDKey.from_string("zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"), "nat", 0, True, "main"): + "bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el", + + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors (first payment address of nested segwit on mainnet) + (HDKey.from_string("ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP"), "nes", 0, False, "main"): + "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", + # jdlcdl: derived via iancoleman test vector for first change address of nested segwit on mainnet + (HDKey.from_string("ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP"), "nes", 0, True, "main"): + "34K56kSjgUCUSD8GTtuF7c9Zzwokbs6uZ7", + + # https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki#test-vectors (first payment address of nested segwit on testnet) + (HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY"), "nes", 0, False, "test"): + "2Mww8dCYPUpKHofjgcXcBCEGmniw9CoaiD2", + # jdlcdl: derived via iancoleman test vector for first change address of nested segwit on testnet + (HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY"), "nes", 0, True, "test"): + "2MvdUi5o3f2tnEFh9yGvta6FzptTZtkPJC8", + + # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors (first payment address of taproot on mainnet) + (HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 0, False, "main"): + "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", + + # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors (second payment address of taproot on mainnet) + (HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 1, False, "main"): + "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", + + # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors (first change address of taproot on mainnet) + (HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 0, True, "main"): + "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7", + + # jdlcdl: derived via electrum m/44'/1'/0 (first payment address p2pkh on testnet) + (HDKey.from_string("tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba"), "leg", 0, False, "test"): + "mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV", + + # jdlcdl: derived via electrum m/44'/1'/0 (first change address p2pkh on testnet) + (HDKey.from_string("tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba"), "leg", 0, True, "test"): + "mi8nhzZgGZQthq6DQHbru9crMDerUdTKva", + + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors (first payment address p2pkh on mainnet) + (HDKey.from_string("xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj"), "leg", 0, False, "main"): + "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", + + # 3rdIteration: derived via electrum m/44'/0'/0 (first change address p2pkh on mainnet) + (HDKey.from_string("xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj"), "leg", 0, True, "main"): + "1J3J6EvPrv8q6AC3VCjWV45Uf3nssNMRtH", + + # jdlcdl: nonsense script_type falls off end of function returning None. TODO: Would it be preferred to "else: raise ValueError"? + (HDKey.from_string("tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba"), "NONSENSE", 0, True, "test"): + "None", + } + func = embit_utils.get_single_sig_address + + print() + for args, expected in vectors_args_expected.items(): + print("\nasserting...") + + # call without optional params (defaults: script_type="nat", index=0, is_change=False, embit_network="main") + if args[1:5] == ("nat", 0, False, "main"): + print(f' {func.__name__}(HDKey.from_string("{args[0]}")) == "{expected}"') + assert str(func(args[0])) == expected + + # call with ordered params + print(f' {func.__name__}(HDKey.from_string("{args[0]}"), *{args[1:5]}) == "{expected}"') + assert str(func(*args)) == expected + + # call with named params + print(f' {func.__name__}(xpub=HDKey.from_string("{args[0]}"), script_type="{args[1]}", index={args[2]}, is_change={args[3]}, embit_network="{args[4]}") == "{expected}"') + assert str(func(xpub=args[0], script_type=args[1], index=args[2], is_change=args[3], embit_network=args[4])) == expected + + +def test_get_multisig_address(): + """ + tests seedsigner.helpers.embit_utils.get_multisig_address() + """ + + from embit.descriptor import Descriptor + + # jdlcdl: these vectors created with electrum & sparrow as a 2 of 3 multisig based on bip39-bip32-standard-path wallets + # keystore1 = 0x00*16 = 73c5da0a = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + # keystore2 = 0x11*16 = 0be174ee = 'baby mass dust captain baby mass dust captain baby mass dust casino' + # keystore3 = 0x22*16 = 8d55ff0d = 'captain baby mass dust captain baby mass dust captain baby mass dutch' + + vector_args_expected = { + # multisig native segwit on testnet, first payment and change addresses + ("wsh(sortedmulti(2,[8d55ff0d/48h/1h/0h/2h]tpubDDxNVWk924RTUhdkVB2uLHw1hGMPNMGufpZefhkkswjbZppVZcuMdjYKQN4ewUog9vbL6RBLFPRWcgTGT7kYP79N6thyJ43ELUs4N2szXMg/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*,[0be174ee/48h/1h/0h/2h]tpubDEsePyLPkbxbrDiZSTTWdsviiNtiQjrvvzZnkLtG72QYLBygEsXePRsTdXi8DeMA7taCuuvoEBjUAfFrsNZeQJqfvG9fFoujYWbFPYUn7ux/{0,1}/*))#zw6cnrlk" , 0, False, "test"): "tb1q7tpecll8jhp77yqdeyt2t8q5swxmmqeh2v22cqpms5dxlp6p27dqlftet8", + ("wsh(sortedmulti(2,[8d55ff0d/48h/1h/0h/2h]tpubDDxNVWk924RTUhdkVB2uLHw1hGMPNMGufpZefhkkswjbZppVZcuMdjYKQN4ewUog9vbL6RBLFPRWcgTGT7kYP79N6thyJ43ELUs4N2szXMg/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*,[0be174ee/48h/1h/0h/2h]tpubDEsePyLPkbxbrDiZSTTWdsviiNtiQjrvvzZnkLtG72QYLBygEsXePRsTdXi8DeMA7taCuuvoEBjUAfFrsNZeQJqfvG9fFoujYWbFPYUn7ux/{0,1}/*))#zw6cnrlk" , 0, True, "test"): "tb1q7h94ywhfjrpxdfzwl4dcawrg80r4rywswjrh447x4n3e5t3m0jms9jh7pm", + + # multisig nested segwit on testnet, first payment and change addresses + ("sh(wsh(sortedmulti(2,[73c5da0a/48h/1h/1h/0h/1h]tpubDFH9dgzveyD8yHQb8VrpG8FYAuwcLMHMje2CCcbBo1FpaGzYVtJeYYxcYgRqSTta5utUFts8nPPHs9C2bqoxrey5jia6Dwf9mpwrPq7YvcJ/{0,1}/*,[0be174ee/48h/1h/0h/1h]tpubDEsePyLPkbxbnj6XuKvWwdERHaKkikZxaGJ9sJqmM7okbZXgkNSFiGU6GX6qEes6kD8f9Z9FosYB9UEnBSgBEyEwwJhj4uUcFE1WE8VtKoh/{0,1}/*,[8d55ff0d/48h/1h/0h/1h]tpubDDxNVWk924RTT3vyGLHdSDoZ2JUVX7jUsPcwCQ9MrKHAtJrW5zECTF9rFHCvqu526E4PjHp61hBknts2c5aGexvX7hvCZ8TGPvQFdzxxy59/{0,1}/*)))#2ujlfp73", 0, False, "test"): "2MtgJH28mZWNWU7VRU4ba6ciFbRRGYWZDt3", + ("sh(wsh(sortedmulti(2,[73c5da0a/48h/1h/1h/0h/1h]tpubDFH9dgzveyD8yHQb8VrpG8FYAuwcLMHMje2CCcbBo1FpaGzYVtJeYYxcYgRqSTta5utUFts8nPPHs9C2bqoxrey5jia6Dwf9mpwrPq7YvcJ/{0,1}/*,[0be174ee/48h/1h/0h/1h]tpubDEsePyLPkbxbnj6XuKvWwdERHaKkikZxaGJ9sJqmM7okbZXgkNSFiGU6GX6qEes6kD8f9Z9FosYB9UEnBSgBEyEwwJhj4uUcFE1WE8VtKoh/{0,1}/*,[8d55ff0d/48h/1h/0h/1h]tpubDDxNVWk924RTT3vyGLHdSDoZ2JUVX7jUsPcwCQ9MrKHAtJrW5zECTF9rFHCvqu526E4PjHp61hBknts2c5aGexvX7hvCZ8TGPvQFdzxxy59/{0,1}/*)))#2ujlfp73", 0, True, "test"): "2NAjjwUQqwD9XRGLeQ6TitSUyMHUz3cLiWm", + + # legacy multisig p2sh on testnet, first payment and change addresses + ("sh(sortedmulti(2,[8d55ff0d/45h]tpubDANogJ2yfnizHwX7fSi5kUVzybyuPXDhgHB2TR9TUvkSLZFW73cRq4STKFDpx7qjJJiisyq82tbu4CeiYtmKEmT1xoCq9P8BPvXV31HUh6d/{0,1}/*,[0be174ee/45h]tpubDBkeVF2tDNT1Pz7L47iJeBB6RokU12LX6x4E6Ph8T89hmjQfB77q1AMyGwL8qpREVGq9sCJEbWwmnemwNTxnpxGn1di7BGy8jx9wEi5Vahu/{0,1}/*,[73c5da0a/45h]tpubDBKsGC1UqBDNvx9aivFmxZNgeZTUnmsCFGhWrqkLzucUCDePvbWWm3n8tAaAwMmxBG2ihdKCG9fzBdUnMxKx5PrkiqSZFi6Vkv6msUs9ddN/{0,1}/*))#p5t8sa8c", 0, False, "test"): "2NBXci43Y2fagvrFYTg3QmXj2LCPU2oaRFH", + ("sh(sortedmulti(2,[8d55ff0d/45h]tpubDANogJ2yfnizHwX7fSi5kUVzybyuPXDhgHB2TR9TUvkSLZFW73cRq4STKFDpx7qjJJiisyq82tbu4CeiYtmKEmT1xoCq9P8BPvXV31HUh6d/{0,1}/*,[0be174ee/45h]tpubDBkeVF2tDNT1Pz7L47iJeBB6RokU12LX6x4E6Ph8T89hmjQfB77q1AMyGwL8qpREVGq9sCJEbWwmnemwNTxnpxGn1di7BGy8jx9wEi5Vahu/{0,1}/*,[73c5da0a/45h]tpubDBKsGC1UqBDNvx9aivFmxZNgeZTUnmsCFGhWrqkLzucUCDePvbWWm3n8tAaAwMmxBG2ihdKCG9fzBdUnMxKx5PrkiqSZFi6Vkv6msUs9ddN/{0,1}/*))#p5t8sa8c", 0, True, "test"): "2MuWQTq7hUGiX1HpXuPRnf7YTM42H5zoEwj", + + # multisig taproot on testnet, not supported + # TODO: find what a multisig-taproot descriptor would look like and add a test so we can fall into the last condition exception. + + # some policy that is not supported: + # TODO: find anything non supported so we can drop off the function: Would it be preferred to "else: raise ValueError()"? + #("sh(multi(2,[8d55ff0d/45h]tpubDANogJ2yfnizHwX7fSi5kUVzybyuPXDhgHB2TR9TUvkSLZFW73cRq4STKFDpx7qjJJiisyq82tbu4CeiYtmKEmT1xoCq9P8BPvXV31HUh6d/{0,1}/*,[0be174ee/45h]tpubDBkeVF2tDNT1Pz7L47iJeBB6RokU12LX6x4E6Ph8T89hmjQfB77q1AMyGwL8qpREVGq9sCJEbWwmnemwNTxnpxGn1di7BGy8jx9wEi5Vahu/{0,1}/*,[73c5da0a/45h]tpubDBKsGC1UqBDNvx9aivFmxZNgeZTUnmsCFGhWrqkLzucUCDePvbWWm3n8tAaAwMmxBG2ihdKCG9fzBdUnMxKx5PrkiqSZFi6Vkv6msUs9ddN/{0,1}/*))#", 0, False, "test"): None, + + } + func = embit_utils.get_multisig_address + + print() + for args, expected in vector_args_expected.items(): + descriptor = Descriptor.from_string(args[0]) + + print("\nasserting...") + + # test successful calls + if type(expected) == str: + # call with optional params (defaults: index=0, is_change=False, embit_network="main") + if args[1:4] == (0, False, 'main'): + print(f' {func.__name__}(Descriptor.from_string("{descriptor}")) == "{expected}"') + assert func(descriptor) == expected + + # call with ordered params + print(f' {func.__name__}(Descriptor.from_string("{descriptor}"), *{args[1:4]}) == "{expected}"') + assert func(descriptor, *args[1:4]) == expected + + # call with named params + print(f' {func.__name__}(descriptor=Descriptor.from_string("{descriptor}"), index={args[1]}, is_change={args[2]}, embit_network="{args[3]}") == "{expected}"') + assert func(descriptor=descriptor, index=args[1], is_change=args[2], embit_network=args[3]) == expected + + # test exceptions + else: + # call with ordered params + with pytest.raises(expected): + print(f' {func.__name__}(Descriptor.from_string("{descriptor}"), *{args[1:4]}) raises Exception') + func(descriptor, *args[1:4]) + + # call with named params + with pytest.raises(expected): + print(f' {func.__name__}(descriptor=Descriptor.from_string("{descriptor}"), index={args[1]}, is_change={args[2]}, embit_network="{args[3]}") raises Exception"') + func(descriptor=descriptor, index=args[1], is_change=args[2], embit_network=args[3]) + + +def test_parse_derivation_path(): + # Shouldn't care if input uses "'" or "h" + derivation_path = "m/84'/0'/0'/0/0" + + result = embit_utils.parse_derivation_path(derivation_path) + assert result["script_type"] == SC.NATIVE_SEGWIT + assert result["network"] == SC.MAINNET + + result = embit_utils.parse_derivation_path(derivation_path.replace("'", "h")) + assert result["script_type"] == SC.NATIVE_SEGWIT + assert result["network"] == SC.MAINNET + + # Now exhaustively test supported permutations + vectors_args = { + (SC.MAINNET, SC.NATIVE_SEGWIT, False): "m/84'/0'/0'/0/5", + (SC.TESTNET, SC.NATIVE_SEGWIT, False): "m/84'/1'/0'/0/5", + (SC.REGTEST, SC.NATIVE_SEGWIT, False): "m/84'/1'/0'/0/5", + (SC.MAINNET, SC.NATIVE_SEGWIT, True): "m/84'/0'/0'/1/5", + (SC.TESTNET, SC.NATIVE_SEGWIT, True): "m/84'/1'/0'/1/5", + (SC.REGTEST, SC.NATIVE_SEGWIT, True): "m/84'/1'/0'/1/5", + + (SC.MAINNET, SC.NESTED_SEGWIT, False): "m/49'/0'/0'/0/5", + (SC.TESTNET, SC.NESTED_SEGWIT, False): "m/49'/1'/0'/0/5", + (SC.REGTEST, SC.NESTED_SEGWIT, False): "m/49'/1'/0'/0/5", + (SC.MAINNET, SC.NESTED_SEGWIT, True): "m/49'/0'/0'/1/5", + (SC.TESTNET, SC.NESTED_SEGWIT, True): "m/49'/1'/0'/1/5", + (SC.REGTEST, SC.NESTED_SEGWIT, True): "m/49'/1'/0'/1/5", + + (SC.MAINNET, SC.TAPROOT, False): "m/86'/0'/0'/0/5", + (SC.TESTNET, SC.TAPROOT, False): "m/86'/1'/0'/0/5", + (SC.REGTEST, SC.TAPROOT, False): "m/86'/1'/0'/0/5", + (SC.MAINNET, SC.TAPROOT, True): "m/86'/0'/0'/1/5", + (SC.TESTNET, SC.TAPROOT, True): "m/86'/1'/0'/1/5", + (SC.REGTEST, SC.TAPROOT, True): "m/86'/1'/0'/1/5", + + (SC.MAINNET, SC.LEGACY_P2PKH, False): "m/44'/0'/0'/0/5", + (SC.TESTNET, SC.LEGACY_P2PKH, False): "m/44'/1'/0'/0/5", + (SC.REGTEST, SC.LEGACY_P2PKH, False): "m/44'/1'/0'/0/5", + (SC.MAINNET, SC.LEGACY_P2PKH, True): "m/44'/0'/0'/1/5", + (SC.TESTNET, SC.LEGACY_P2PKH, True): "m/44'/1'/0'/1/5", + (SC.REGTEST, SC.LEGACY_P2PKH, True): "m/44'/1'/0'/1/5", + + # Try a typical custom derivation path (Unchained vault keys) + (SC.MAINNET, SC.CUSTOM_DERIVATION, False): "m/45'/0'/0'/0/5", + (SC.TESTNET, SC.CUSTOM_DERIVATION, False): "m/45'/1'/0'/0/5", + (SC.REGTEST, SC.CUSTOM_DERIVATION, False): "m/45'/1'/0'/0/5", + (SC.MAINNET, SC.CUSTOM_DERIVATION, True): "m/45'/0'/0'/1/5", + (SC.TESTNET, SC.CUSTOM_DERIVATION, True): "m/45'/1'/0'/1/5", + (SC.REGTEST, SC.CUSTOM_DERIVATION, True): "m/45'/1'/0'/1/5", + + # CRAZY custom derivation paths + (None, SC.CUSTOM_DERIVATION, False, 5): "m/123'/9083270/9083270/9083270/9083270/0/5", + + # non-standard change and/or index + (None, SC.CUSTOM_DERIVATION, None, 5): "m/9'/78/5", + (None, SC.CUSTOM_DERIVATION, None, 5): "m/9'/78'/5", + (None, SC.CUSTOM_DERIVATION, None, None): "m/9'/78'/5'", + (None, SC.CUSTOM_DERIVATION, False, None): "m/9'/0/5'", + } + + for expected_result, derivation_path in vectors_args.items(): + actual_result = embit_utils.parse_derivation_path(derivation_path) + + if expected_result[0] == SC.MAINNET: + assert actual_result["network"] == expected_result[0] + assert actual_result["clean_match"] is True + elif expected_result[0] is None: + assert actual_result["network"] is None + assert actual_result["clean_match"] is False + else: + # Testnet and regtest are returned as a list since the parser can't tell which is intended + assert expected_result[0] in actual_result["network"] + assert actual_result["clean_match"] is True + + assert actual_result["script_type"] == expected_result[1] + assert actual_result["is_change"] == expected_result[2] + + if len(expected_result) == 4: + assert actual_result["index"] == expected_result[3] + else: + assert actual_result["index"] == int(derivation_path.split("/")[-1]) diff --git a/tests/test_encodepsbtqr.py b/tests/test_encodepsbtqr.py index 0dc5c0f4..14feb1e0 100644 --- a/tests/test_encodepsbtqr.py +++ b/tests/test_encodepsbtqr.py @@ -1,82 +1,52 @@ -import pytest -from mock import MagicMock -from seedsigner.models import EncodeQR, QRType +from seedsigner.models.encode_qr import CompactSeedQrEncoder, SeedQrEncoder, SpecterXPubQrEncoder, StaticXpubQrEncoder, UrPsbtQrEncoder, UrXpubQrEncoder from embit import psbt from binascii import a2b_base64 from seedsigner.models.settings import SettingsConstants +from seedsigner.models.seed import Seed -def test_ur_qr_encode(): +def test_ur_psbt_qr_encode(): base64_psbt = "cHNidP8BAIkCAAAAAaLlQ/VRNpx3IFtoRTOCnq2xfJwg/n7R9XB0TTTnlX/UHQAAAAD9////AtzQAwAAAAAAIgAgCwVSg4Ae1lGNHzy76jLN6GSQaSVnktnmNDByu/wkn7FQwwAAAAAAACIAIJyFZJe7xxQjXpoEBhb8mIkau9OhobDS7xbYxnRIjJUSAAAAAE8BBIiyHgQFgrfagAAAAqP8rWjHFRBmTEWK39AFjd6Wo1sw1UxlgIvROVHUOHbiAzre+t61zOqKFV1xXtDPuUcQRh3M92zh0Zar8rDLPJKQFH7fnFkwAACAAAAAgAAAAIACAACATwEEiLIeBFIg7+eAAAACubwMfJNby3zfn9owhFfgl/Xe/GiHciMMxxB9v6q7BWcCurV9rH+K8ucVU3w52mcEttDldz7kh5cS0xBtWs7wmTYU4IEbazAAAIAAAACAAAAAgAIAAIBPAQSIsh4EX+8GLoAAAALvSlncnGchVCfK7tnzHPVYcBRcck0JGQuspGFpcGP+YQIAXYODa8PIF3hOOnUeYHhlv4PQ+UZCYynQCOoKgVJRhhQYTQfrMAAAgAAAAIAAAACAAgAAgE8BBIiyHgRgkAVVgAAAAsgLKl/ahhLHvS/3Cth+9Hde12MHJO5PP8REKtbWkqONAvETqIlMPWJ/f1uBvSCGFm+zzDYnnEBtuAYjZiQrzj9mFLQz4JUwAACAAAAAgAAAAIACAACATwEEiLIeBLQlJwmAAAAC4IOLeQD9ojcPbh5QGsPVUt/g+dCiQrlZ1DvZK21ajf8CN4aND6VGGhYiFtI9NNyna/M03ovmM4PSg3nR7Df9jsoUhSswjzAAAIAAAACAAAAAgAIAAIBPAQSIsh4EwYVAaYAAAAKvbrl5PeuwgEBUqMQqBYTaTR+PUfKrOXzPQ87VbyLgXwMFEpYG8cv4ljYX+uebG0hJLXsD8K9Lc9K2RqaBmFOtyBQ+RR7+MAAAgAAAAIAAAACAAgAAgAABAP0DDAIAAAADnI5jmO6QLNrEFUwjGd8ZaVBeFqwJGZ3APH1mGpO+GU1CAAAAAP////8tMJlbqdEddNCzmBnmZXSdFFfNTTzD8fd0L2l15pJNWwIAAAAA/////+zKvZECNrGUsrUdWJZnB42n6r1Rhi1XkTyPs/nHuhyoFAAAAAD/////WlvbBAAAAAAAF6kUoY7q7sUcfiktUmzDPQGi//fFvXyHb44BAAAAAAAXqRTHugXLmX3w/lCFhTkfalnedRIHrIeGWAIAAAAAABl2qRQUGQLrLqWokxaHzt65bE2Qle3FQYishdwFAAAAAAAZdqkUX4m0fmwPCUO6pcw8Zbx2YkykKIyIrNACJwAAAAAAFgAUuej6+oAPU186R0ACxtFVG1po+oU7EQUAAAAAABl2qRTGnZD1MfBn92OncmMkRD2Ea+fToYisEksCAAAAAAAXqRS8oz8RN18jt3aeydd/y+/StoEsdYcLajcAAAAAABepFHeYIqSnDz5GuBfk0XbjLOlqNF6Dhy4IFwAAAAAAGXapFDomdwaFLA0OfjLfhXXxGYcjwi94iKyGWAIAAAAAABl2qRSdfDacqPqO9CPL8VtX8vldzlBo6YisSVgCAAAAAAAWABR0DohsF88/3DalzIZyF2ZToibTSxXkLQAAAAAAGXapFPmlzMsqjXuIMCBczer3vGR+GX7KiKwyQAIAAAAAABl2qRTt97UCSLs90250ctaRfDmj6KvZzYis9QgXAAAAAAAWABS526HCIlmm4yis1TxaDNbeCCGyHkakAQAAAAAAF6kUIv85Uai5pFu94QXUYU6YV6ZY/HmHR8gBAAAAAAAZdqkU4cbRwoLlhic5Mx6SdsH8m8bF1nqIrNBvBgAAAAAAGXapFFBUM1drdEjYVzE3ZQOodhobVeNBiKwdSwIAAAAAABl2qRRV48mXLau6y2HwytGPyw/YWXIDR4isD+UGAAAAAAAZdqkUhup0yRlrxdycP9543gF4HEdYVX6IrEiVBAAAAAAAF6kU0xyHPo/mQeDTWX3uHecVQr3QNwmHLjUDAAAAAAAZdqkUR4aV0HjC/bVmOwfjXsbcMzVsbSKIrOqKBAAAAAAAFgAUEx8rhKUzD3fHWdK2v6R9xHvFqHpMhQIAAAAAABl2qRRIg2u4Ow74IDCcGKYnssKpOi2VeYisN6YbAAAAAAAXqRRCSDT9kY3HvkNQI480GmDcu8fffocMQQMAAAAAABl2qRQ83W+Njx6fiXXbXE0fFR9QfA0zJoisdggXAAAAAAAXqRQ9/3PVGaBETVlM+auG6MBXkqNa6YeQt04AAAAAABl2qRRHmF4qf2fYqxhuYPulQCJIhkKyo4isfY4BAAAAAAAXqRQb2OkwwU0kbjeTepBie2hH9Nyhy4d4oAIAAAAAABepFLxzGvLxJoys+l4fvCfHyKNfzx5XhzeVBAAAAAAAIgAgWE9I4MhYpgx9StM1jKekhXHNQ8ohBTlx4N8wbBvDeil4QAIAAAAAABepFJhDXqvM/jN8FZw/lHXkusDCJRTgh2q4AgAAAAAAF6kUtcjnRyRtAOawAnBvMygHecHHRCiHIIkLAAAAAAAZdqkU7X7qc767ZmR51ucTsc5G3uu4XDCIrNBnAwAAAAAAF6kU1SVFH4lwDMpvZEe/g4OXPA/R9EeHcIMDAAAAAAAXqRSGSbWdefvglK8rcFv861TKX7H4Lod63AUAAAAAABepFDspcMlIbqM0IOnk4iOp+VVWsIeIh1zsAQAAAAAAGXapFHrgjevUcXAC9y3FSqtB4O6YHBf9iKxiyAQAAAAAABYAFJWB2pija792dDLax+k7ko3rc3MTmdUBAAAAAAAWABTEd9Lq4RQ5DqWrFEJG0yGwTyNGVmzIAQAAAAAAF6kUvQ1oX0X+EqSr9nm5yVgTEqqwbBKH/jEJAAAAAAAWABS5Q9n7WwpD8V+DoUr1PhtaPjEAzpw4AwAAAAAAGXapFJV07D6tUzUodx2WS8O5Co4x365viKxedB8AAAAAABl2qRQwCg19nxZttgRYVNtqm634kcvwI4islXUXAAAAAAAXqRQdpv7S8UHAtUzhN9UjDzbA1r8l3odrcAMAAAAAABepFFY77ZQ2QH8SBYqU4lswOS2SM3NphxXvCQAAAAAAF6kUQIs3Q5sPU0ubPufbIGTl5aforUWHXtACAAAAAAAZdqkUomTncvcva43IhQjLUn/gkddAA4+IrL1sAwAAAAAAF6kUF0IHuQXB2WY+UKhOt2Fe1PP0YKeHziuGAwAAAAAWABQmwHI2oSXayEsODm4irKumczJcu2hZDQAAAAAAF6kUkEVhUQ33XDK3OqdRZDvT9lpyh5CHoNMJAAAAAAAXqRSoGqG/VTHq7TmPlXD2YYU8ih0HQYdqfgsAAAAAABl2qRTJ1SEupAnvPWOSxpcXFnbfVCx2r4issK0BAAAAAAAXqRTb7/iq9K3zhZIGk0VpFBtYiVJ724cDdRcAAAAAABl2qRRHkxnD6L4pYcssWJdqkDrkja7kmYisokoCAAAAAAAXqRT75VCujkWKFY/ifu/0Orj3JUV0Z4ezjAQAAAAAABYAFBpX8ddXQJ95Vy/v3zi+yYZVi/yglrAEAAAAAAAXqRSLFecvMVMAuxjHz6iSn0XpfQ98gIdNTRgAAAAAABepFB3Wn0gQsayX8cOkCtmSF/NRy08zh5QUBwAAAAAAGXapFLbjX9PnCBzJKViLkLVzyMwtVwNUiKyJSQUAAAAAABepFNDbsKF4ZizxSBuY7NHYnOEuPemxh9LxWwAAAAAAF6kUquecjseAlaEpHxPc83v8kGUFdkuHLzQIAAAAAAAXqRRX8vVVucqqFgH6mGJPEK+/reJ2oYd03AUAAAAAABepFBsBZvNXH4r6Ro8ojq4rmGTcNtBih9maAQAAAAAAGXapFF9oCUBEmn9pA1ddXZGZjsfEFsMOiKzEzwUAAAAAABl2qRRxJyHXAGx4sfRS9WH4eyJLHi+Wd4isLfotAAAAAAAXqRSORo5USPBTLgHEvyfKgfjCqMhnm4csWAIAAAAAABl2qRSRQ+PgB7THRZz/rts1ZV1kB3xCU4ishkoCAAAAAAAZdqkU6tpAL4E1Y53hpyNDyup0NNkIWZ2IrHaaAQAAAAAAF6kUdCiIVs9RAe6mhTnBrZ9rXDmBUwqHMRUHAAAAAAAWABRWDPxl5JVG+87QXnn6mxroeokXegbVCgAAAAAAF6kUc1nZFyA2yQQlxjG7wC8EmcwSYAaHkooLAAAAAAAZdqkUbWMloEkzLZaPkqmvj48ayjP24pWIrKXBCQAAAAAAF6kUhor0BRMQSHMrs8huHLt3PzkmwY+HLTQCAAAAAAAXqRRu2+r5RZ97rALhlGzLcTqXL0qWBYdtdQIAAAAAABepFOvvX6e4KHStEF9gAeP5sueSWoj8h8xKAgAAAAAAF6kUtCOpxVPaoJX6I6x4sYcxi0FRkZuHHEsCAAAAAAAXqRR0jXC8f5rOvLMnaCqNbFhgYV1VI4eynwUAAAAAABepFKk9GBH39jPYAijN98mQiXQLwO6th6KVBAAAAAAAF6kUxAXYvAMMGpeUwbSehQ6yl7PfBKaHhAwGAAAAAAAXqRSgEqI18gMoa8oDed3Nmw0e0JjANodsEAIAAAAAABl2qRSZlcV+iJuoM2F5GdNLhAmJB8LZkYisD9QBAAAAAAAXqRTltOpfMjLJA3a0569jL3OdK96kLYeQXgIAAAAAABl2qRRJxL+Ewl1I7R0UVRYyhvyTdhGt+Yisg0oCAAAAAAAZdqkUqzUSwEEJDrGszAlQNOTOyiXHGc6IrH0qCQAAAAAAF6kUA9gkZnXrwD3nSird5PjY/mKrjrKHrZUEAAAAAAAXqRR2GK6PRPCUdeDBifrkXqVW6OjTVocldRcAAAAAABYAFIALieV/hlNyLSnLzygXuapZ5ZWOeFACAAAAAAAXqRQzJK44f4kGcK0Mr67rQIf8V6K004eNCBcAAAAAABepFEPC9GKHNg91b0VHjiGqN9jskJBnh3wYBgAAAAAAF6kUac+U//Z6fP0Sd1hF+7H2spE6W3uHAAAAAAEBKzeVBAAAAAAAIgAgWE9I4MhYpgx9StM1jKekhXHNQ8ohBTlx4N8wbBvDeikBBc9UIQI90obbwglkzCu7YY5szpmsifPSjmmkMWB2zirsF7i5JSECXtSG8zlgDJHpslDlTL+/MPiyMHW404co4O9XwhrFJD4hAsaYDVoTjPJ1xm5KIpmVjO8AerWFj+0ij7ti1GkxvyI/IQMNJ5G2tHM6GGX9OMrL1a5LLFjx3eyHE9dG8/00BGJ6+yEDW0BA9BSig0YYQcMhaCQ5EgJhYPx0HfMNsknOEzNVBfkhA4/77ELJ9rT3+zhaRN/L3lk81Eie5dlCI15SuNT45ZV+Vq4iBgI90obbwglkzCu7YY5szpmsifPSjmmkMWB2zirsF7i5JRw+RR7+MAAAgAAAAIAAAACAAgAAgAAAAAABAAAAIgYCXtSG8zlgDJHpslDlTL+/MPiyMHW404co4O9XwhrFJD4c4IEbazAAAIAAAACAAAAAgAIAAIAAAAAAAQAAACIGAsaYDVoTjPJ1xm5KIpmVjO8AerWFj+0ij7ti1GkxvyI/HIUrMI8wAACAAAAAgAAAAIACAACAAAAAAAEAAAAiBgMNJ5G2tHM6GGX9OMrL1a5LLFjx3eyHE9dG8/00BGJ6+xwYTQfrMAAAgAAAAIAAAACAAgAAgAAAAAABAAAAIgYDW0BA9BSig0YYQcMhaCQ5EgJhYPx0HfMNsknOEzNVBfkctDPglTAAAIAAAACAAAAAgAIAAIAAAAAAAQAAACIGA4/77ELJ9rT3+zhaRN/L3lk81Eie5dlCI15SuNT45ZV+HH7fnFkwAACAAAAAgAAAAIACAACAAAAAAAEAAAAAAQHPVCEC5eStpJd5y6MpbkWgUYRhL6Sta3BAtONOSEC2uIXXIcEhAw5hli91LeHlLHv5WR6/xjfFTjCsXxE9MtO0wV/a7mTnIQMT9IzdgTJDxQ0CO5Ka1HcnXfbBnCdLN9NZrDKMf3Z+WSEDn6BiNDZ7YI//rSuZjrNIY0k0C3h7MBEur/nzJ7gVF08hA7UGbXn9OfXGcHLWujN7D1wpZqwQrOV49XIiJNtqr6dFIQPwycXFPO4Rf5xaNDQ1zryEERu4z+A3C6iz0+aKHfHq4VauIgIC5eStpJd5y6MpbkWgUYRhL6Sta3BAtONOSEC2uIXXIcEcGE0H6zAAAIAAAACAAAAAgAIAAIABAAAAAAAAACICAw5hli91LeHlLHv5WR6/xjfFTjCsXxE9MtO0wV/a7mTnHOCBG2swAACAAAAAgAAAAIACAACAAQAAAAAAAAAiAgMT9IzdgTJDxQ0CO5Ka1HcnXfbBnCdLN9NZrDKMf3Z+WRx+35xZMAAAgAAAAIAAAACAAgAAgAEAAAAAAAAAIgIDn6BiNDZ7YI//rSuZjrNIY0k0C3h7MBEur/nzJ7gVF08cPkUe/jAAAIAAAACAAAAAgAIAAIABAAAAAAAAACICA7UGbXn9OfXGcHLWujN7D1wpZqwQrOV49XIiJNtqr6dFHLQz4JUwAACAAAAAgAAAAIACAACAAQAAAAAAAAAiAgPwycXFPO4Rf5xaNDQ1zryEERu4z+A3C6iz0+aKHfHq4RyFKzCPMAAAgAAAAIAAAACAAgAAgAEAAAAAAAAAAAEBz1QhAqLp+NQOoYyma8paUW8hucqCdQu2VAZmFGMbV79csI7jIQKtZYJ+sgBVWQwp/xCIeS/x+/SZXAD4VHf56HFmnK9fkyECrvaSdw5m5ZxvwhF7/EbFGJP5MGIDhdbdcILAGsept4shAwlGvi1FP2ybbd5xYnQhz7Cvh2gWaTn5yvMVWm+Ev5keIQPCy/yDc1y1RCJYDMEy6UYkduq4Eq1dyLOoInv5xwsitSED0sEPo41jUtW51+oiJDQPHFt0scWX6aPHivum+kT7WBhWriICAqLp+NQOoYyma8paUW8hucqCdQu2VAZmFGMbV79csI7jHIUrMI8wAACAAAAAgAAAAIACAACAAAAAAAIAAAAiAgKtZYJ+sgBVWQwp/xCIeS/x+/SZXAD4VHf56HFmnK9fkxzggRtrMAAAgAAAAIAAAACAAgAAgAAAAAACAAAAIgICrvaSdw5m5ZxvwhF7/EbFGJP5MGIDhdbdcILAGsept4scGE0H6zAAAIAAAACAAAAAgAIAAIAAAAAAAgAAACICAwlGvi1FP2ybbd5xYnQhz7Cvh2gWaTn5yvMVWm+Ev5keHH7fnFkwAACAAAAAgAAAAIACAACAAAAAAAIAAAAiAgPCy/yDc1y1RCJYDMEy6UYkduq4Eq1dyLOoInv5xwsitRy0M+CVMAAAgAAAAIAAAACAAgAAgAAAAAACAAAAIgID0sEPo41jUtW51+oiJDQPHFt0scWX6aPHivum+kT7WBgcPkUe/jAAAIAAAACAAAAAgAIAAIAAAAAAAgAAAAA=" tx = psbt.PSBT.parse(a2b_base64(base64_psbt)) - e = EncodeQR(psbt=tx, qr_type=QRType.PSBT__UR2) + e = UrPsbtQrEncoder(psbt=tx, qr_density=SettingsConstants.DENSITY__MEDIUM) cnt = 0 while cnt <= 10: fragment = e.next_part() - img = e.part_to_image(fragment) - cnt += 1 - - - -def test_specter_qr_encode(): - base64_psbt = "cHNidP8BAIkCAAAAAaLlQ/VRNpx3IFtoRTOCnq2xfJwg/n7R9XB0TTTnlX/UHQAAAAD9////AtzQAwAAAAAAIgAgCwVSg4Ae1lGNHzy76jLN6GSQaSVnktnmNDByu/wkn7FQwwAAAAAAACIAIJyFZJe7xxQjXpoEBhb8mIkau9OhobDS7xbYxnRIjJUSAAAAAE8BBIiyHgQFgrfagAAAAqP8rWjHFRBmTEWK39AFjd6Wo1sw1UxlgIvROVHUOHbiAzre+t61zOqKFV1xXtDPuUcQRh3M92zh0Zar8rDLPJKQFH7fnFkwAACAAAAAgAAAAIACAACATwEEiLIeBFIg7+eAAAACubwMfJNby3zfn9owhFfgl/Xe/GiHciMMxxB9v6q7BWcCurV9rH+K8ucVU3w52mcEttDldz7kh5cS0xBtWs7wmTYU4IEbazAAAIAAAACAAAAAgAIAAIBPAQSIsh4EX+8GLoAAAALvSlncnGchVCfK7tnzHPVYcBRcck0JGQuspGFpcGP+YQIAXYODa8PIF3hOOnUeYHhlv4PQ+UZCYynQCOoKgVJRhhQYTQfrMAAAgAAAAIAAAACAAgAAgE8BBIiyHgRgkAVVgAAAAsgLKl/ahhLHvS/3Cth+9Hde12MHJO5PP8REKtbWkqONAvETqIlMPWJ/f1uBvSCGFm+zzDYnnEBtuAYjZiQrzj9mFLQz4JUwAACAAAAAgAAAAIACAACATwEEiLIeBLQlJwmAAAAC4IOLeQD9ojcPbh5QGsPVUt/g+dCiQrlZ1DvZK21ajf8CN4aND6VGGhYiFtI9NNyna/M03ovmM4PSg3nR7Df9jsoUhSswjzAAAIAAAACAAAAAgAIAAIBPAQSIsh4EwYVAaYAAAAKvbrl5PeuwgEBUqMQqBYTaTR+PUfKrOXzPQ87VbyLgXwMFEpYG8cv4ljYX+uebG0hJLXsD8K9Lc9K2RqaBmFOtyBQ+RR7+MAAAgAAAAIAAAACAAgAAgAABAP0DDAIAAAADnI5jmO6QLNrEFUwjGd8ZaVBeFqwJGZ3APH1mGpO+GU1CAAAAAP////8tMJlbqdEddNCzmBnmZXSdFFfNTTzD8fd0L2l15pJNWwIAAAAA/////+zKvZECNrGUsrUdWJZnB42n6r1Rhi1XkTyPs/nHuhyoFAAAAAD/////WlvbBAAAAAAAF6kUoY7q7sUcfiktUmzDPQGi//fFvXyHb44BAAAAAAAXqRTHugXLmX3w/lCFhTkfalnedRIHrIeGWAIAAAAAABl2qRQUGQLrLqWokxaHzt65bE2Qle3FQYishdwFAAAAAAAZdqkUX4m0fmwPCUO6pcw8Zbx2YkykKIyIrNACJwAAAAAAFgAUuej6+oAPU186R0ACxtFVG1po+oU7EQUAAAAAABl2qRTGnZD1MfBn92OncmMkRD2Ea+fToYisEksCAAAAAAAXqRS8oz8RN18jt3aeydd/y+/StoEsdYcLajcAAAAAABepFHeYIqSnDz5GuBfk0XbjLOlqNF6Dhy4IFwAAAAAAGXapFDomdwaFLA0OfjLfhXXxGYcjwi94iKyGWAIAAAAAABl2qRSdfDacqPqO9CPL8VtX8vldzlBo6YisSVgCAAAAAAAWABR0DohsF88/3DalzIZyF2ZToibTSxXkLQAAAAAAGXapFPmlzMsqjXuIMCBczer3vGR+GX7KiKwyQAIAAAAAABl2qRTt97UCSLs90250ctaRfDmj6KvZzYis9QgXAAAAAAAWABS526HCIlmm4yis1TxaDNbeCCGyHkakAQAAAAAAF6kUIv85Uai5pFu94QXUYU6YV6ZY/HmHR8gBAAAAAAAZdqkU4cbRwoLlhic5Mx6SdsH8m8bF1nqIrNBvBgAAAAAAGXapFFBUM1drdEjYVzE3ZQOodhobVeNBiKwdSwIAAAAAABl2qRRV48mXLau6y2HwytGPyw/YWXIDR4isD+UGAAAAAAAZdqkUhup0yRlrxdycP9543gF4HEdYVX6IrEiVBAAAAAAAF6kU0xyHPo/mQeDTWX3uHecVQr3QNwmHLjUDAAAAAAAZdqkUR4aV0HjC/bVmOwfjXsbcMzVsbSKIrOqKBAAAAAAAFgAUEx8rhKUzD3fHWdK2v6R9xHvFqHpMhQIAAAAAABl2qRRIg2u4Ow74IDCcGKYnssKpOi2VeYisN6YbAAAAAAAXqRRCSDT9kY3HvkNQI480GmDcu8fffocMQQMAAAAAABl2qRQ83W+Njx6fiXXbXE0fFR9QfA0zJoisdggXAAAAAAAXqRQ9/3PVGaBETVlM+auG6MBXkqNa6YeQt04AAAAAABl2qRRHmF4qf2fYqxhuYPulQCJIhkKyo4isfY4BAAAAAAAXqRQb2OkwwU0kbjeTepBie2hH9Nyhy4d4oAIAAAAAABepFLxzGvLxJoys+l4fvCfHyKNfzx5XhzeVBAAAAAAAIgAgWE9I4MhYpgx9StM1jKekhXHNQ8ohBTlx4N8wbBvDeil4QAIAAAAAABepFJhDXqvM/jN8FZw/lHXkusDCJRTgh2q4AgAAAAAAF6kUtcjnRyRtAOawAnBvMygHecHHRCiHIIkLAAAAAAAZdqkU7X7qc767ZmR51ucTsc5G3uu4XDCIrNBnAwAAAAAAF6kU1SVFH4lwDMpvZEe/g4OXPA/R9EeHcIMDAAAAAAAXqRSGSbWdefvglK8rcFv861TKX7H4Lod63AUAAAAAABepFDspcMlIbqM0IOnk4iOp+VVWsIeIh1zsAQAAAAAAGXapFHrgjevUcXAC9y3FSqtB4O6YHBf9iKxiyAQAAAAAABYAFJWB2pija792dDLax+k7ko3rc3MTmdUBAAAAAAAWABTEd9Lq4RQ5DqWrFEJG0yGwTyNGVmzIAQAAAAAAF6kUvQ1oX0X+EqSr9nm5yVgTEqqwbBKH/jEJAAAAAAAWABS5Q9n7WwpD8V+DoUr1PhtaPjEAzpw4AwAAAAAAGXapFJV07D6tUzUodx2WS8O5Co4x365viKxedB8AAAAAABl2qRQwCg19nxZttgRYVNtqm634kcvwI4islXUXAAAAAAAXqRQdpv7S8UHAtUzhN9UjDzbA1r8l3odrcAMAAAAAABepFFY77ZQ2QH8SBYqU4lswOS2SM3NphxXvCQAAAAAAF6kUQIs3Q5sPU0ubPufbIGTl5aforUWHXtACAAAAAAAZdqkUomTncvcva43IhQjLUn/gkddAA4+IrL1sAwAAAAAAF6kUF0IHuQXB2WY+UKhOt2Fe1PP0YKeHziuGAwAAAAAWABQmwHI2oSXayEsODm4irKumczJcu2hZDQAAAAAAF6kUkEVhUQ33XDK3OqdRZDvT9lpyh5CHoNMJAAAAAAAXqRSoGqG/VTHq7TmPlXD2YYU8ih0HQYdqfgsAAAAAABl2qRTJ1SEupAnvPWOSxpcXFnbfVCx2r4issK0BAAAAAAAXqRTb7/iq9K3zhZIGk0VpFBtYiVJ724cDdRcAAAAAABl2qRRHkxnD6L4pYcssWJdqkDrkja7kmYisokoCAAAAAAAXqRT75VCujkWKFY/ifu/0Orj3JUV0Z4ezjAQAAAAAABYAFBpX8ddXQJ95Vy/v3zi+yYZVi/yglrAEAAAAAAAXqRSLFecvMVMAuxjHz6iSn0XpfQ98gIdNTRgAAAAAABepFB3Wn0gQsayX8cOkCtmSF/NRy08zh5QUBwAAAAAAGXapFLbjX9PnCBzJKViLkLVzyMwtVwNUiKyJSQUAAAAAABepFNDbsKF4ZizxSBuY7NHYnOEuPemxh9LxWwAAAAAAF6kUquecjseAlaEpHxPc83v8kGUFdkuHLzQIAAAAAAAXqRRX8vVVucqqFgH6mGJPEK+/reJ2oYd03AUAAAAAABepFBsBZvNXH4r6Ro8ojq4rmGTcNtBih9maAQAAAAAAGXapFF9oCUBEmn9pA1ddXZGZjsfEFsMOiKzEzwUAAAAAABl2qRRxJyHXAGx4sfRS9WH4eyJLHi+Wd4isLfotAAAAAAAXqRSORo5USPBTLgHEvyfKgfjCqMhnm4csWAIAAAAAABl2qRSRQ+PgB7THRZz/rts1ZV1kB3xCU4ishkoCAAAAAAAZdqkU6tpAL4E1Y53hpyNDyup0NNkIWZ2IrHaaAQAAAAAAF6kUdCiIVs9RAe6mhTnBrZ9rXDmBUwqHMRUHAAAAAAAWABRWDPxl5JVG+87QXnn6mxroeokXegbVCgAAAAAAF6kUc1nZFyA2yQQlxjG7wC8EmcwSYAaHkooLAAAAAAAZdqkUbWMloEkzLZaPkqmvj48ayjP24pWIrKXBCQAAAAAAF6kUhor0BRMQSHMrs8huHLt3PzkmwY+HLTQCAAAAAAAXqRRu2+r5RZ97rALhlGzLcTqXL0qWBYdtdQIAAAAAABepFOvvX6e4KHStEF9gAeP5sueSWoj8h8xKAgAAAAAAF6kUtCOpxVPaoJX6I6x4sYcxi0FRkZuHHEsCAAAAAAAXqRR0jXC8f5rOvLMnaCqNbFhgYV1VI4eynwUAAAAAABepFKk9GBH39jPYAijN98mQiXQLwO6th6KVBAAAAAAAF6kUxAXYvAMMGpeUwbSehQ6yl7PfBKaHhAwGAAAAAAAXqRSgEqI18gMoa8oDed3Nmw0e0JjANodsEAIAAAAAABl2qRSZlcV+iJuoM2F5GdNLhAmJB8LZkYisD9QBAAAAAAAXqRTltOpfMjLJA3a0569jL3OdK96kLYeQXgIAAAAAABl2qRRJxL+Ewl1I7R0UVRYyhvyTdhGt+Yisg0oCAAAAAAAZdqkUqzUSwEEJDrGszAlQNOTOyiXHGc6IrH0qCQAAAAAAF6kUA9gkZnXrwD3nSird5PjY/mKrjrKHrZUEAAAAAAAXqRR2GK6PRPCUdeDBifrkXqVW6OjTVocldRcAAAAAABYAFIALieV/hlNyLSnLzygXuapZ5ZWOeFACAAAAAAAXqRQzJK44f4kGcK0Mr67rQIf8V6K004eNCBcAAAAAABepFEPC9GKHNg91b0VHjiGqN9jskJBnh3wYBgAAAAAAF6kUac+U//Z6fP0Sd1hF+7H2spE6W3uHAAAAAAEBKzeVBAAAAAAAIgAgWE9I4MhYpgx9StM1jKekhXHNQ8ohBTlx4N8wbBvDeikBBc9UIQI90obbwglkzCu7YY5szpmsifPSjmmkMWB2zirsF7i5JSECXtSG8zlgDJHpslDlTL+/MPiyMHW404co4O9XwhrFJD4hAsaYDVoTjPJ1xm5KIpmVjO8AerWFj+0ij7ti1GkxvyI/IQMNJ5G2tHM6GGX9OMrL1a5LLFjx3eyHE9dG8/00BGJ6+yEDW0BA9BSig0YYQcMhaCQ5EgJhYPx0HfMNsknOEzNVBfkhA4/77ELJ9rT3+zhaRN/L3lk81Eie5dlCI15SuNT45ZV+Vq4iBgI90obbwglkzCu7YY5szpmsifPSjmmkMWB2zirsF7i5JRw+RR7+MAAAgAAAAIAAAACAAgAAgAAAAAABAAAAIgYCXtSG8zlgDJHpslDlTL+/MPiyMHW404co4O9XwhrFJD4c4IEbazAAAIAAAACAAAAAgAIAAIAAAAAAAQAAACIGAsaYDVoTjPJ1xm5KIpmVjO8AerWFj+0ij7ti1GkxvyI/HIUrMI8wAACAAAAAgAAAAIACAACAAAAAAAEAAAAiBgMNJ5G2tHM6GGX9OMrL1a5LLFjx3eyHE9dG8/00BGJ6+xwYTQfrMAAAgAAAAIAAAACAAgAAgAAAAAABAAAAIgYDW0BA9BSig0YYQcMhaCQ5EgJhYPx0HfMNsknOEzNVBfkctDPglTAAAIAAAACAAAAAgAIAAIAAAAAAAQAAACIGA4/77ELJ9rT3+zhaRN/L3lk81Eie5dlCI15SuNT45ZV+HH7fnFkwAACAAAAAgAAAAIACAACAAAAAAAEAAAAAAQHPVCEC5eStpJd5y6MpbkWgUYRhL6Sta3BAtONOSEC2uIXXIcEhAw5hli91LeHlLHv5WR6/xjfFTjCsXxE9MtO0wV/a7mTnIQMT9IzdgTJDxQ0CO5Ka1HcnXfbBnCdLN9NZrDKMf3Z+WSEDn6BiNDZ7YI//rSuZjrNIY0k0C3h7MBEur/nzJ7gVF08hA7UGbXn9OfXGcHLWujN7D1wpZqwQrOV49XIiJNtqr6dFIQPwycXFPO4Rf5xaNDQ1zryEERu4z+A3C6iz0+aKHfHq4VauIgIC5eStpJd5y6MpbkWgUYRhL6Sta3BAtONOSEC2uIXXIcEcGE0H6zAAAIAAAACAAAAAgAIAAIABAAAAAAAAACICAw5hli91LeHlLHv5WR6/xjfFTjCsXxE9MtO0wV/a7mTnHOCBG2swAACAAAAAgAAAAIACAACAAQAAAAAAAAAiAgMT9IzdgTJDxQ0CO5Ka1HcnXfbBnCdLN9NZrDKMf3Z+WRx+35xZMAAAgAAAAIAAAACAAgAAgAEAAAAAAAAAIgIDn6BiNDZ7YI//rSuZjrNIY0k0C3h7MBEur/nzJ7gVF08cPkUe/jAAAIAAAACAAAAAgAIAAIABAAAAAAAAACICA7UGbXn9OfXGcHLWujN7D1wpZqwQrOV49XIiJNtqr6dFHLQz4JUwAACAAAAAgAAAAIACAACAAQAAAAAAAAAiAgPwycXFPO4Rf5xaNDQ1zryEERu4z+A3C6iz0+aKHfHq4RyFKzCPMAAAgAAAAIAAAACAAgAAgAEAAAAAAAAAAAEBz1QhAqLp+NQOoYyma8paUW8hucqCdQu2VAZmFGMbV79csI7jIQKtZYJ+sgBVWQwp/xCIeS/x+/SZXAD4VHf56HFmnK9fkyECrvaSdw5m5ZxvwhF7/EbFGJP5MGIDhdbdcILAGsept4shAwlGvi1FP2ybbd5xYnQhz7Cvh2gWaTn5yvMVWm+Ev5keIQPCy/yDc1y1RCJYDMEy6UYkduq4Eq1dyLOoInv5xwsitSED0sEPo41jUtW51+oiJDQPHFt0scWX6aPHivum+kT7WBhWriICAqLp+NQOoYyma8paUW8hucqCdQu2VAZmFGMbV79csI7jHIUrMI8wAACAAAAAgAAAAIACAACAAAAAAAIAAAAiAgKtZYJ+sgBVWQwp/xCIeS/x+/SZXAD4VHf56HFmnK9fkxzggRtrMAAAgAAAAIAAAACAAgAAgAAAAAACAAAAIgICrvaSdw5m5ZxvwhF7/EbFGJP5MGIDhdbdcILAGsept4scGE0H6zAAAIAAAACAAAAAgAIAAIAAAAAAAgAAACICAwlGvi1FP2ybbd5xYnQhz7Cvh2gWaTn5yvMVWm+Ev5keHH7fnFkwAACAAAAAgAAAAIACAACAAAAAAAIAAAAiAgPCy/yDc1y1RCJYDMEy6UYkduq4Eq1dyLOoInv5xwsitRy0M+CVMAAAgAAAAIAAAACAAgAAgAAAAAACAAAAIgID0sEPo41jUtW51+oiJDQPHFt0scWX6aPHivum+kT7WBgcPkUe/jAAAIAAAACAAAAAgAIAAIAAAAAAAgAAAAA=" - - tx = psbt.PSBT.parse(a2b_base64(base64_psbt)) - - e = EncodeQR(psbt=tx, qr_type=QRType.PSBT__SPECTER) - - cnt = 0 - while cnt <= 10: - fragment = e.next_part() - if (cnt+1) == 1: - assert fragment == "p1of117 cHNidP8BAIkCAAAAAaLlQ/VRNpx3IFtoRTOCnq2xfJwg/n7R9XB0TTTnlX/UHQAAA" - elif (cnt+1) == 2: - assert fragment == "p2of117 AD9////AtzQAwAAAAAAIgAgCwVSg4Ae1lGNHzy76jLN6GSQaSVnktnmNDByu/wkn7" - elif (cnt+1) == 3: - assert fragment == "p3of117 FQwwAAAAAAACIAIJyFZJe7xxQjXpoEBhb8mIkau9OhobDS7xbYxnRIjJUSAAAAAE8" - elif (cnt+1) == 4: - assert fragment == "p4of117 BBIiyHgQFgrfagAAAAqP8rWjHFRBmTEWK39AFjd6Wo1sw1UxlgIvROVHUOHbiAzre" - elif (cnt+1) == 5: - assert fragment == "p5of117 +t61zOqKFV1xXtDPuUcQRh3M92zh0Zar8rDLPJKQFH7fnFkwAACAAAAAgAAAAIACA" - elif (cnt+1) == 6: - assert fragment == "p6of117 ACATwEEiLIeBFIg7+eAAAACubwMfJNby3zfn9owhFfgl/Xe/GiHciMMxxB9v6q7BW" - elif (cnt+1) == 7: - assert fragment == "p7of117 cCurV9rH+K8ucVU3w52mcEttDldz7kh5cS0xBtWs7wmTYU4IEbazAAAIAAAACAAAA" - elif (cnt+1) == 8: - assert fragment == "p8of117 AgAIAAIBPAQSIsh4EX+8GLoAAAALvSlncnGchVCfK7tnzHPVYcBRcck0JGQuspGFp" - elif (cnt+1) == 9: - assert fragment == "p9of117 cGP+YQIAXYODa8PIF3hOOnUeYHhlv4PQ+UZCYynQCOoKgVJRhhQYTQfrMAAAgAAAA" - elif (cnt+1) == 10: - assert fragment == "p10of117 IAAAACAAgAAgE8BBIiyHgRgkAVVgAAAAsgLKl/ahhLHvS/3Cth+9Hde12MHJO5PP8" - elif (cnt+1) == 11: - assert fragment == "p11of117 REKtbWkqONAvETqIlMPWJ/f1uBvSCGFm+zzDYnnEBtuAYjZiQrzj9mFLQz4JUwAAC" - - img = e.part_to_image(fragment) + e.part_to_image(fragment, 240, 240) cnt += 1 def test_seedsigner_qr(): - mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" + # 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) + assert e.next_part() == "011513251154012711900771041507421289190620080870026613431420201617920614089619290300152408010643" - e = EncodeQR(seed_phrase=mnemonic.split(), qr_type=QRType.SEED__SEEDQR) + e = CompactSeedQrEncoder(mnemonic=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' - print(e.next_part()) + # 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) + assert e.next_part() == "073318950739065415961602009907670428187212261116" - assert e.next_part() == "121802020768124106400009195602431595117715840445" + e = CompactSeedQrEncoder(mnemonic=mnemonic) + assert e.next_part() == b'[\xbd\x9dq\xa8\xecy\x90\x83\x1a\xff5\x9dBeE' def test_xpub_qr(): mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" - e = EncodeQR(seed_phrase=mnemonic.split(), passphrase="pass", qr_type=QRType.XPUB, network=SettingsConstants.TESTNET, derivation="m/48h/1h/0h/2h") - + 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" @@ -84,7 +54,7 @@ def test_xpub_qr(): def test_specter_xpub_qr(): mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" - e = EncodeQR(seed_phrase=mnemonic.split(" "), passphrase="pass", qr_type=QRType.XPUB__SPECTER, 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" @@ -96,10 +66,8 @@ def test_specter_xpub_qr(): def test_ur_xpub_qr(): mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" - e = EncodeQR( - seed_phrase=mnemonic.split(), - passphrase="pass", - qr_type=QRType.XPUB__UR, + e = UrXpubQrEncoder( + 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.py b/tests/test_flows.py new file mode 100644 index 00000000..acb8e8a3 --- /dev/null +++ b/tests/test_flows.py @@ -0,0 +1,159 @@ +import pytest + +# Must import test base before the Controller +from base import FlowTest, FlowStep, FlowTestMissingRedirectException, FlowTestUnexpectedRedirectException, FlowTestUnexpectedViewException, FlowTestInvalidButtonDataSelectionException + +from seedsigner.controller import Controller +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON +from seedsigner.models.seed import Seed +from seedsigner.views import scan_views +from seedsigner.views.psbt_views import PSBTSelectSeedView +from seedsigner.views.seed_views import SeedBackupView, SeedMnemonicEntryView, SeedOptionsView, SeedsMenuView +from seedsigner.views.view import MainMenuView, PowerOptionsView, UnhandledExceptionView +from seedsigner.views.tools_views import ToolsMenuView, ToolsCalcFinalWordNumWordsView + + + +class TestFlowTest(FlowTest): + + def test_simple_flow(self): + """ + Basic test to ensure the FlowTest can flow through a sequence of Views and + terminate via the StopControllerCommand. + """ + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD), + FlowStep(ToolsCalcFinalWordNumWordsView, button_data_selection=ToolsCalcFinalWordNumWordsView.TWELVE), + FlowStep(SeedMnemonicEntryView), + ]) + + + def test_FlowTestUnexpectedViewException(self): + """ + Ensure that the FlowTest will raise a FlowTestUnexpectedViewException if the next + View in the sequence is not the expected View. + """ + with pytest.raises(FlowTestUnexpectedViewException): + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=RET_CODE__POWER_BUTTON), + FlowStep(ToolsMenuView), # <-- Wrong target View! + ]) + + + def test_UnhandledExceptionView(self): + """ + This is a regression test to ensure that the FlowTest is aware of exceptions that + redirect to the UnhandledExceptionView. If that isn't the expected View, the + FlowTest should raise a FlowTestUnexpectedViewException. + """ + # This sequence simulates a FlowTest that is unaware of an exception that will + # derail the sequence (i.e. somebody wrote a bad FlowTest or something unexpected + # is breaking). The sequence should fail with FlowTestUnexpectedViewException. + with pytest.raises(FlowTestUnexpectedViewException): + self.run_sequence([ + FlowStep(PSBTSelectSeedView), # <-- There is no PSBT loaded. Should raise an exception that routes us to the UnhandledExceptionView. + FlowStep(scan_views.ScanSeedQRView), # <-- This is not the View we'll end up at; FlowTest should raise the FlowTestUnexpectedViewException + ]) + + # This sequence *expects* an exception to route us to the UnhandledExceptionView + # and therefore can complete successfully. + self.run_sequence([ + FlowStep(PSBTSelectSeedView), # <-- There's no PSBT loaded. + FlowStep(UnhandledExceptionView), + ]) + + + def test_FlowTestInvalidButtonDataSelectionException(self): + """ + Ensure that the FlowTest will raise a FlowTestUnexpectedViewException if the + specified button_data_selection in invalid. + """ + with pytest.raises(FlowTestInvalidButtonDataSelectionException): + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection="this is not a real button option!"), + ]) + + + def test_FlowTestUnexpectedRedirectException(self): + """ + If the FlowStep doesn't specify is_redirect when the View redirects, raise FlowTestUnexpectedRedirectException + """ + with pytest.raises(FlowTestUnexpectedRedirectException) as e: + self.run_sequence([ + FlowStep(SeedsMenuView, button_data_selection=SeedsMenuView.LOAD), # <-- No seeds loaded, so it'll redirect elsewhere + ]) + + # This time we'll show that we know it should redirect + self.run_sequence([ + FlowStep(SeedsMenuView, is_redirect=True), + ]) + + + def test_FlowTestMissingRedirectException(self): + """ + If the FlowStep specifies is_redirect but the View does NOT redirect, raise FlowTestMissingRedirectException + """ + with pytest.raises(FlowTestMissingRedirectException): + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS, is_redirect=True), + ]) + + + def test_before_run_executes(self): + """ + Ensure that the FlowTest can execute a function before running a View. + """ + # TODO + pass + + + def test_back_button_flow(self): + """ + Ensure that the FlowTest works correctly with the Controller and its BackStack. + """ + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), + FlowStep(PowerOptionsView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(MainMenuView), + ]) + + + def test_initial_destination(self): + """ + Ensure that the FlowTest can start from a View other than MainMenuView. + """ + # Don't have to start at the MainMenuView; can jump straight in + self.run_sequence([ + FlowStep(ToolsCalcFinalWordNumWordsView), + ]) + + # And again, but this time with a View that requires input view_args + self.reset_controller() + self.controller = Controller.get_instance() + + # Load a seed into the Controller + seed = Seed(mnemonic=["abandon "* 11 + "about"]) + self.controller.storage.set_pending_seed(seed) + self.controller.storage.finalize_pending_seed() + + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(SeedOptionsView, button_data_selection=SeedOptionsView.BACKUP), + FlowStep(SeedBackupView), + ] + ) + + + def test_raise_exception_via_screen_return_value(self): + """ + Ensure that the FlowTest can raise an exception via the screen_return_value. + """ + # A generic Exception should be caught by the Controller and routed to the + # UnhandledExceptionView. + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=Exception("Test exception")), + FlowStep(UnhandledExceptionView), + ]) + diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py new file mode 100644 index 00000000..964b0d67 --- /dev/null +++ b/tests/test_flows_psbt.py @@ -0,0 +1,172 @@ +from base import FlowTest, FlowStep + +from seedsigner.controller import Controller +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): + """ + Selecting "Scan" from the MainMenuView and scanning a PSBT should enter the PSBTSelectSeedView flow + when Scan a Seed is selected from PSBTSelectSeedView it should enter the ScanView flow + when a SeedQR is scanned it should enter the PSBTOverviewView flow + since the PSBT has change no warning is displayed and it should enter the PSBTMathView flow + since the PSBT is not a self transfer it should enter the PSBTAddressDetailsView flow + """ + def load_psbt_into_decoder(view: scan_views.ScanView): + """ + PSBT Tx and Wallet Details + - Single Sig Wallet P2WPKH (Native Segwit) with no passphrase + - Regtest c751dc07 m/84'/1'/0' tpubDDZBrnxMxbVzqt8EoEiABPxeKzFWma5pra5UEbg3Wst1hrwr6feuvcy7Sov7cpuYx94ypuy1PQ9NDNoQagFs37wGALzLb5Ei3FvyJWPPPKZ + - 2 Inputs + - 56,522,834 sats + - 1,990,245,069 sats + - 4 Outputs + - 1 Output to another wallet (bcrt1q7cw0wzy8g6mq5qvkpvhnk5gsps5ncy3srp0n2j) of 123,456 sats + - 3 Outputs change + - 3 outputs to emulate a fake mix to increase privacy + - Change addresses are index 1/7, 1/8, 1/9 + - 1/7 address bcrt1q53j0xwuskuf5gnvynadh0hlazyy8srydlucrhg with amount 123,456 sats + - 1/8 address bcrt1q5gtw3zfp4cx67yk5q42q6j6rfza8aqcwpyyslv with amount 1,990,121,477 sats + - 1/9 address bcrt1q9rrg7399m43cn0yg4tz0v0ate89jgf2d6kpz7v with amount 56,399,242 sats + - Fee 272 sats + """ + view.decoder.add_data("cHNidP8BANgCAAAAAsTXZs3fz/dmGb6M80+jjvJZdYya+cw5bT/dGuhZFdSlAAAAAAD9////qo6xg/UZAvUkcbse1F+C9zbP/FeZNjThx7SCIn6eMCgBAAAAAP3///8EQOIBAAAAAAAWABSkZPM7kLcTRE2En1t33/0RCHgMjQXYnnYAAAAAFgAUKMaPRKXdY4m8iKrE9j+rycskJU1A4gEAAAAAABYAFPYc9wiHRrYKAZYLLztREAwpPBIwipVcAwAAAAAWABSiFuiJIa4NrxLUBVQNS0NIun6DDtoRAABPAQQ1h88DBcQGZIAAAAA+0J+jlNL3dpWwlnBi8Dx+Ipg4e6uvB3HdjzFPX7r9CAOOlAIxgII+/xCcj+XoEenKH7wj5s5wlu7Q7CCZWFLGLhA5Su0UVAAAgAEAAIAAAACAAAEA7QIAAAAEE6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0BAAAAAP3///8TqeNf9+e+fuFuQohFzHM1gU5J9sJ015ad3sV7/VRxDQMAAAAA/f///xOp41/3575+4W5CiEXMczWBTkn2wnTXlp3exXv9VHENBAAAAAD9////E6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0GAAAAAP3///8CUnheAwAAAAAWABRCfygPJ+Fjsx4BknYvvm3A3qKn2xJ/XQcAAAAAF6kU1I4TAst5nAj15ey7vwe5cM3OFq+HlhEAAAEBH1J4XgMAAAAAFgAUQn8oDyfhY7MeAZJ2L75twN6ip9sBAwQBAAAAIgYCo7sfm78RQY3B5n0ac/QF8VtMAzFnci+h5D1MtpgRY7oYOUrtFFQAAIABAACAAAAAgAEAAAAGAAAAAAEAcQIAAAABxY7wh0nsfJQfzWrD/9rN9BYsM+iOmPaO6I0ANFgO/PcAAAAAAP3///8CptiUAAAAAAAWABRIm4HhQY/TzOjeWSPRrbuJo9MlW826oHYAAAAAFgAU0z+0L2QSLGtyQTn8FhbCpcI7jbliAQAAAQEfzbqgdgAAAAAWABTTP7QvZBIsa3JBOfwWFsKlwjuNuQEDBAEAAAAiBgITHmebEANk81CraV4xZIpqkNjjw0tIvezl1Ism1NRH3Rg5Su0UVAAAgAEAAIAAAACAAQAAAAAAAAAAIgICuTT7WnuiUTpObjWnZFHzIeEvW9PTB+1LLVFNQJVFeIIYOUrtFFQAAIABAACAAAAAgAEAAAAHAAAAACICAk8f3hpc5C35chgSg+Pe2zZ9IhHREd4aKW2+yAMRIFeqGDlK7RRUAACAAQAAgAAAAIABAAAACQAAAAAAIgIDjt1CjvrnMMnjbmTNKUAYoKEDRbmKjNjbq+6Ppqj3bqQYOUrtFFQAAIABAACAAAAAgAEAAAAIAAAAAA==") + + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("080115060387063104071857067618681125136207731354") + + 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.PSBTAddressDetailsView, button_data_selection=0), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), + FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT), + FlowStep(psbt_views.PSBTSignedQRDisplayView), + FlowStep(MainMenuView) + ]) + + + def test_scan_psbt_first_then_load_electrum_seed(self): + """ + Should be able to load an Electrum mnemonic after first loading in a psbt. + """ + def load_psbt_into_decoder(view: scan_views.ScanView): + # Single sig psbt for the below Electrum mnemonic + view.decoder.add_data("cHNidP8BAHECAAAAAX9/d6VyI7nvVTyhLBfqu05za2AJ2Z0dKMC0cUX+S2U7AQAAAAD9////AgeHAAAAAAAAFgAUOnNPuZMD1sQudt3+7LvHBUvGhyd//gAAAAAAABYAFGO9QLvu4V9/hz6ZjbIGMrqsEiIYAjQTAAABAR+ghgEAAAAAABYAFKawrgcT62jmIVQwyHPCV0thmJWbAQDBAQAAAAABAYeHL9UQlz/jEKUuNNY3LTeQRjudjBinsP2L0ppvgRt0AAAAAAD/////AnbP3rsPAAAAIlEgtgmCioGjfKwp6f8rOoI4OPb+ZV8db581J9IizZPskl2ghgEAAAAAABYAFKawrgcT62jmIVQwyHPCV0thmJWbAUDCBlMh9VjZN2NdU9Wabi0o3Ct1q9YHTsJRLAkLfUuIHB+BE+ucR4bdGAJG5nBhCWOmCXbpRwKP1INRYvkuQ2fHAAAAACIGA2+PEYHyVy6nhYwAx5SJKBIWXjsWgjhhf/2FEWqXgxnoEKNOC3gAAACAAAAAAAAAAAAAACICA0SBeeHxfHdny6rUnQJuteAnQ7shSydexjJCkSJarn3mEKNOC3gAAACAAQAAAAEAAAAA") + + self.settings.set_value(SettingsConstants.SETTING__ELECTRUM_SEEDS, SettingsConstants.OPTION__ENABLED) + + 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.TYPE_ELECTRUM), + FlowStep(seed_views.SeedElectrumMnemonicStartView), + ] + + # Load the associated Electrum mnemonic during the flow + for word in "apple drip silly junior language resource unaware whale snake copy gravity tank".split(): + sequence += [ + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word), + ] + + sequence += [ + 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), + ] + + self.run_sequence(sequence) + + + def test_scan_multisig_psbt_seed_already_signed_flow(self): + + def load_psbt_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("cHNidP8BAIkCAAAAAc9dCSh2RcRPfHaT5bNVBpbg0jAekRLqOK+bpN/QA0jeAAAAAAD9////AtAHAAAAAAAAIlEg24shYsV3IRCzlgmMKjAsR4Ad9tX896z7zDAi5q0TU9H3CgAAAAAAACIAIByGQg/VP2aRID62ty40E64HYZeRRsKRGLt8J/76R6stQ04FAE8BBDWHzwSLLGdzgAAAAq3q6nR20JnHR+vKrBQdWxN9C7xU8zNX942mVF7AQpl2ArrdLwVlkGxaatQJ4wwkvypNBKbwOq9hXGLNlKi7rZWAFDUxzXUwAACAAQAAgAAAAIACAACATwEENYfPBHOCZmWAAAACmH6KTXIny0vueRgQFBq4M6oMuG8f1QM0I/RzKQ03bCgCHrF0fyUtV0+FD2N34u/woqb8MAt/o+7Ed58RddhY8zYUCUjSaDAAAIABAACAAAAAgAIAAIAAAQEriBMAAAAAAAAiACBY4WsjDgJXLj3VW222jU1tkIIhT26ce/2efH73BWGGBiICAqyfkrdUO662QBrdvJcSOZMFxniD7M1awm9U0Kb5XCm5RzBEAiAPkQTY84YjFFkpD6MI2cc5rJySqws5fsTQA/8XEZFpbAIgTNVykbEH4Z7bqyzhhy6lty0K8rtCUDCaHNv+47NNIWgBAQMEAQAAAAEFR1IhApL4XO+VE1pPYn5wnRFyJQKVSc9TX2dO6KIBH6jwvgPaIQKsn5K3VDuutkAa3byXEjmTBcZ4g+zNWsJvVNCm+VwpuVKuIgYCkvhc75UTWk9ifnCdEXIlApVJz1NfZ07oogEfqPC+A9ocNTHNdTAAAIABAACAAAAAgAIAAIAAAAAAAAAAACIGAqyfkrdUO662QBrdvJcSOZMFxniD7M1awm9U0Kb5XCm5HAlI0mgwAACAAQAAgAAAAIACAACAAAAAAAAAAAAAAAEBR1IhApYXaczuYbBM/A+EH639Ir2yIB4PxL46dK/I1V1O9aHgIQLa02HCI/+EP+9gGpxHskjYWFN5hZzXY7RRvwV4UF42ylKuIgIClhdpzO5hsEz8D4Qfrf0ivbIgHg/Evjp0r8jVXU71oeAcNTHNdTAAAIABAACAAAAAgAIAAIABAAAAAAAAACICAtrTYcIj/4Q/72AanEeySNhYU3mFnNdjtFG/BXhQXjbKHAlI0mgwAACAAQAAgAAAAIACAACAAQAAAAAAAAAA") + + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("073318950739065415961602009907670428187212261116") + + 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.PSBTAddressDetailsView, button_data_selection=0), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.SKIP_VERIFICATION), + FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT), + 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=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), + FlowStep(psbt_views.PSBTOverviewView), + FlowStep(psbt_views.PSBTMathView), + FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.SKIP_VERIFICATION), + FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT), + 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 new file mode 100644 index 00000000..f6ef501d --- /dev/null +++ b/tests/test_flows_seed.py @@ -0,0 +1,666 @@ +from typing import Callable +import pytest + +# Must import test base before the Controller +from base import BaseTest, FlowTest, FlowStep +from base import FlowTestInvalidButtonDataSelectionException + +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON +from seedsigner.models.settings import Settings, SettingsConstants +from seedsigner.models.seed import ElectrumSeed, Seed +from seedsigner.views.view import ErrorView, MainMenuView, OptionDisabledView, View, NetworkMismatchErrorView +from seedsigner.views import seed_views, scan_views, settings_views + + +def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("0000" * 11 + "0003") + + + +class TestSeedFlows(FlowTest): + + def test_scan_seedqr_flow(self): + """ + Selecting "Scan" from the MainMenuView and scanning a SeedQR should enter the + Finalize Seed flow and end at the SeedOptionsView. + """ + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=load_seed_into_decoder), # simulate read SeedQR; ret val is ignored + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView), + ]) + + + def test_passphrase_entry_flow(self): + """ + Opting to add a bip39 passphrase on the Finalize Seed screen should enter the + passphrase entry / review flow and end at the SeedOptionsView. + """ + 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=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=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")), + FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.EDIT), + FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="muhpassphrase")), + FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE), + FlowStep(seed_views.SeedOptionsView), + ]) + + + def test_mnemonic_entry_flow(self): + """ + Manually entering a mnemonic should land at the Finalize Seed flow and end at + the SeedOptionsView. + """ + def test_with_mnemonic(mnemonic): + Settings.HOSTNAME = "not seedsigner-os" + sequence = [ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), + FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView + FlowStep(seed_views.LoadSeedView, button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD), + ] + + # Now add each manual word entry step + for word in mnemonic: + sequence.append( + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word) + ) + + # With the mnemonic completely entered, we land on the SeedFinalizeView + sequence += [ + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView), + ] + + self.run_sequence(sequence) + + # Test data from iancoleman.io; 12- and 24-word mnemonic + test_with_mnemonic("tone flat shed cool census soul paddle boy flight fantasy stem social".split()) + + BaseTest.reset_controller() + + test_with_mnemonic("cotton artefact spy mind wing there echo steak child oak awful host despair online bicycle divorce middle firm diamond rare execute chimney almost hollow".split()) + + + def test_invalid_mnemonic(self): + """ Should be able to go back and edit or discard an invalid mnemonic """ + # Test data from iancoleman.io + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + sequence = [ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), + FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView + FlowStep(seed_views.LoadSeedView, button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD), + ] + for word in mnemonic[:-1]: + sequence.append(FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word)) + + sequence += [ + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value="zoo"), # But finish with an INVALID checksum word + FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.EDIT), + ] + + # Restarts from first word + for word in mnemonic[:-1]: + sequence.append(FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word)) + + sequence += [ + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value="zebra"), # provide yet another invalid checksum word + FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.DISCARD), + FlowStep(MainMenuView), + ] + + self.run_sequence(sequence) + + + def test_electrum_mnemonic_entry_flow(self): + """ + Manually entering an Electrum mnemonic should land at the Finalize Seed flow and end at + the SeedOptionsView. + + Most BIP-39 mnemonics should generate an error if entered as Electrum seeds. + """ + def test_with_mnemonic(mnemonic: list[str], custom_extension: str = None, expects_electrum_seed_is_valid: bool = True): + Settings.HOSTNAME = "not seedsigner-os" + settings = Settings.get_instance() + settings.set_value(SettingsConstants.SETTING__ELECTRUM_SEEDS, SettingsConstants.OPTION__ENABLED) + + sequence = [ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), + FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView + FlowStep(seed_views.LoadSeedView, button_data_selection=seed_views.LoadSeedView.TYPE_ELECTRUM), + FlowStep(seed_views.SeedElectrumMnemonicStartView), # Warning screen; no relevant button data selection. + ] + + # Now add each manual word entry step + for word in mnemonic: + sequence.append( + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word) + ) + + if expects_electrum_seed_is_valid: + # With the mnemonic completely entered, we land on the SeedFinalizeView + if custom_extension: + sequence += [ + FlowStep(seed_views.SeedFinalizeView, screen_return_value=1), # The passphrase / custom extension button is dynamic so there's no constant to refer to here + FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase=custom_extension)), # This is a one-off oddity where the Screen returns dict instead of int | str + FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE), + FlowStep(seed_views.SeedOptionsView), + ] + else: + sequence += [ + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView), + ] + + else: + # Or we bomb out if the mnemonic is invalid for Electrum + sequence.append(FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.DISCARD)) + + self.run_sequence(sequence) + + BaseTest.reset_controller() + + + # Test seeds generated by Electrum v4.5.5 + test_with_mnemonic("bomb congress scorpion mutual word stamp tongue valid permit salmon yellow spy".split()) + test_with_mnemonic("morning pretty hobby click extend color wait joke define sausage boost salmon".split()) + test_with_mnemonic("basket print toy noodle betray weird filter ticket insect copy force machine".split()) + test_with_mnemonic("basket print toy noodle betray weird filter ticket insect copy force machine".split(), custom_extension="test") + test_with_mnemonic("basket print toy noodle betray weird filter ticket insect copy force machine".split(), custom_extension="monkey fling orange coin good") + + # Most BIP-39 seeds should fail; test seed generated by bitcoiner.guide + test_with_mnemonic("pioneer divide volcano art victory family grow novel mandate bicycle senior adjust".split(), expects_electrum_seed_is_valid=False) + + + def test_export_xpub_standard_flow(self): + """ + Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView + """ + + def flowtest_standard_xpub(sig_tuple, script_tuple, coord_tuple): + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_tuple[1]), + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=script_tuple[1]), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=coord_tuple[1]), + FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), + FlowStep(MainMenuView), + ] + ) + + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + # these are lists of (constant_value, display_name) tuples + sig_types: list[tuple[str, str]] = SettingsConstants.ALL_SIG_TYPES + script_types: list[tuple[str, str]] = SettingsConstants.ALL_SCRIPT_TYPES + coordinators: list[tuple[str, str]] = SettingsConstants.ALL_COORDINATORS + + # enable non-defaults so they're available in views + self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in sig_types]) + self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in script_types]) + self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in coordinators]) + + # exhaustively test flows thru standard sig_types, script_types, and coordinators + for sig_tuple in sig_types: + for script_tuple in script_types: + for coord_tuple in coordinators: + # skip custom derivation + if script_tuple[0] == SettingsConstants.CUSTOM_DERIVATION: + continue + # skip multisig taproot + elif sig_tuple[0] == SettingsConstants.MULTISIG and script_tuple[0] == SettingsConstants.TAPROOT: + continue + else: + print('\n\ntest_standard_xpubs(%s, %s, %s)' % (sig_tuple, script_tuple, coord_tuple)) + flowtest_standard_xpub(sig_tuple, script_tuple, coord_tuple) + + + def test_export_xpub_disabled_not_available_flow(self): + """ + If sig_type/script_type/coordinator disabled, then these options are not available + """ + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + # these are lists of (constant_value, display_name) tuples + sig_types: list[tuple[str, str]] = SettingsConstants.ALL_SIG_TYPES + script_types: list[tuple[str, str]] = SettingsConstants.ALL_SCRIPT_TYPES + coordinators: list[tuple[str, str]] = SettingsConstants.ALL_COORDINATORS + + # these are the disabled types that we will be testing + disabled_sig = SettingsConstants.MULTISIG + disabled_script = SettingsConstants.TAPROOT + disabled_coord = SettingsConstants.COORDINATOR__NUNCHUK + + # enable all but our target disabled type + self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in sig_types if x!=disabled_sig]) + self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in script_types if x!=disabled_script]) + self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in coordinators if x!=disabled_coord]) + + # If multisig isn't an option, then the sig type selection is skipped altogether + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView), + ] + ) + + # test that taproot is not an option via exception raised when choice is taproot + with pytest.raises(FlowTestInvalidButtonDataSelectionException) as e: + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=disabled_script), + ] + ) + + # test that nunchuk is not an option via exception raised when choice is nunchuk + with pytest.raises(FlowTestInvalidButtonDataSelectionException) as e: + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=disabled_coord), + ] + ) + + + def test_export_xpub_custom_derivation_flow(self): + """ + Export XPUB flow for custom derivation finishes at MainMenuView + """ + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + # enable custom derivation script_type setting (plus at least one more for a choice) + self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [ + SettingsConstants.NATIVE_SEGWIT, + SettingsConstants.NESTED_SEGWIT, + SettingsConstants.CUSTOM_DERIVATION + ]) + + # get display names to access button choices in the views (ugh: hardcoding, is there a better way?) + sig_type = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SIG_TYPES)[0] # single sig + script_type = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES)[2] # custom derivation + coordinator = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[3] # specter + + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_type), + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=script_type), + FlowStep(seed_views.SeedExportXpubCustomDerivationView, screen_return_value="m/0'/0'"), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=coordinator), + FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), + FlowStep(MainMenuView), + ] + ) + + + def test_export_xpub_skip_non_option_flow(self): + """ + Export XPUB flows w/o user choices when no other options for sig_types, script_types, and/or coordinators + """ + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + # exclusively set only one choice for each of sig_types, script_types and coordinators + self.settings.update({ + SettingsConstants.SETTING__SIG_TYPES: SettingsConstants.MULTISIG, + SettingsConstants.SETTING__SCRIPT_TYPES: SettingsConstants.NESTED_SEGWIT, + SettingsConstants.SETTING__COORDINATORS: SettingsConstants.COORDINATOR__SPECTER_DESKTOP, + }) + + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubCoordinatorView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), + FlowStep(MainMenuView), + ] + ) + + + def test_export_xpub_electrum_seed_flow(self): + """ + Electrum seeds should skip script type selection + """ + # Load a finalized Seed into the Controller + self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) + self.controller.storage.set_pending_seed(ElectrumSeed("regular reject rare profit once math fringe chase until ketchup century escape".split())) + self.controller.storage.finalize_pending_seed() + + # Make sure all options are enabled + self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in SettingsConstants.ALL_SIG_TYPES]) + self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in SettingsConstants.ALL_SCRIPT_TYPES]) + self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in SettingsConstants.ALL_COORDINATORS]) + + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=seed_views.SeedExportXpubSigTypeView.SINGLE_SIG), + + # Skips past the script type options via redirect + FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[0]), + FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), + FlowStep(MainMenuView), + ] + ) + + + def test_discard_seed_flow(self): + """ + Selecting "Discard Seed" from the SeedOptionsView should enter the Discard Seed flow and + remove the in-memory seed from the Controller. + """ + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.DISCARD), + FlowStep(seed_views.SeedDiscardView, button_data_selection=seed_views.SeedDiscardView.DISCARD), + FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), + FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView + FlowStep(seed_views.LoadSeedView), + ] + ) + + + +class TestMessageSigningFlows(FlowTest): + MAINNET_DERIVATION_PATH = "m/84h/0h/0h/0/0" + TESTNET_DERIVATION_PATH = "m/84h/1h/0h/0/0" + CUSTOM_DERIVATION_PATH = "m/99h/0/0" + SHORT_MESSAGE = "I attest that I control this bitcoin address blah blah blah" + NO_WHITESPACE_MESSAGE = """{"height":841407,"lightning_bolt12":"lno1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}""" + MULTIPAGE_MESSAGE = """Chancellor on brink of second bailout for banks + + Billions may be needed as lending squeeze tightens + + Alistair Darling has been forced to consider a second bailout for banks as the lending drought worsens. + + The Chancellor will decide within weeks whether to pump billions more into the economy as evidence mounts that the £37 billion part-nationalisation last year has failed to keep credit flowing. Options include cash injections, offering banks cheaper state guarantees to raise money privately or buying up “toxic assets”, The Times has learnt.""" + + + def load_seed_into_decoder(self, view: scan_views.ScanView): + view.decoder.add_data("0000" * 11 + "0003") + + + def load_signmessage_into_decoder(self, view:View, derivation_path: str, message: str): + view.decoder.add_data(f"signmessage {derivation_path} ascii:{message}") + + + def load_short_message_into_decoder(self, view: View): + self.load_signmessage_into_decoder(view, self.MAINNET_DERIVATION_PATH, self.SHORT_MESSAGE) + + + def load_testnet_message_into_decoder(self, view: View): + self.load_signmessage_into_decoder(view, self.TESTNET_DERIVATION_PATH, self.SHORT_MESSAGE) + + + def load_multipage_message_into_decoder(self, view: View): + self.load_signmessage_into_decoder(view, self.MAINNET_DERIVATION_PATH, self.MULTIPAGE_MESSAGE) + + + def load_no_whitespace_message_into_decoder(self, view: View): + self.load_signmessage_into_decoder(view, self.MAINNET_DERIVATION_PATH, self.NO_WHITESPACE_MESSAGE) + + + def load_custom_derivation_into_decoder(self, view: View): + self.load_signmessage_into_decoder(view, self.CUSTOM_DERIVATION_PATH, self.SHORT_MESSAGE) + + + def inject_mesage_as_paged_message(self, view: View): + # Because the Screen won't actually run, we have to do the Screen's work here + from seedsigner.gui.components import reflow_text_into_pages, GUIConstants + paged = reflow_text_into_pages( + text=self.controller.sign_message_data["message"], + width=240 - 2*GUIConstants.EDGE_PADDING, + height=240 - GUIConstants.TOP_NAV_HEIGHT - 3*GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT, + allow_text_overflow=True, + ) + self.controller.sign_message_data["paged_message"] = paged + + + def test_sign_message_flow(self): + """ + Should scan a `signmessage` QR and complete the message review, address review, + and signing flow. + """ + # Ensure message signing is enabled + self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED) + + # Scenario 1: Load the mesage first, then the seed + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=self.load_short_message_into_decoder), # simulate read message QR; ret val is ignored + FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True), + FlowStep(seed_views.SeedSelectSeedView, button_data_selection=seed_views.SeedSelectSeedView.SCAN_SEED), + FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(seed_views.SeedSignMessageConfirmMessageView, before_run=self.inject_mesage_as_paged_message, screen_return_value=0), + FlowStep(seed_views.SeedSignMessageConfirmAddressView, screen_return_value=0), + FlowStep(seed_views.SeedSignMessageSignedMessageQRView, screen_return_value=0), + FlowStep(MainMenuView), + ]) + + # Scenario 2: Scan the seed first, then select Sign Message + self.controller.discard_seed(0) + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.SIGN_MESSAGE), + FlowStep(scan_views.ScanView, before_run=self.load_short_message_into_decoder), # simulate read message QR; ret val is ignored + FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True), + FlowStep(seed_views.SeedSignMessageConfirmMessageView, before_run=self.inject_mesage_as_paged_message, screen_return_value=0), + FlowStep(seed_views.SeedSignMessageConfirmAddressView, screen_return_value=0), + FlowStep(seed_views.SeedSignMessageSignedMessageQRView, screen_return_value=0), + FlowStep(MainMenuView), + ]) + + # Scenario 3: Load a long, multipage message + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=self.load_multipage_message_into_decoder), # simulate read message QR; ret val is ignored + FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True), + FlowStep(seed_views.SeedSelectSeedView, button_data_selection=seed_views.SeedSelectSeedView.SCAN_SEED), + FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(seed_views.SeedSignMessageConfirmMessageView, before_run=self.inject_mesage_as_paged_message, screen_return_value=0), # page 1/5 + FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 2/5 + FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 3/5 + FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 4/5 + FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 5/5 + + # Arrive at the address confirmation, then go backwards to re-review the paged message + FlowStep(seed_views.SeedSignMessageConfirmAddressView, screen_return_value=RET_CODE__BACK_BUTTON), # then back to page 5/5 + FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=RET_CODE__BACK_BUTTON), # back to page 4/5 + FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=RET_CODE__BACK_BUTTON), # back to page 3/5 + FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=RET_CODE__BACK_BUTTON), # back to page 2/5 + FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=RET_CODE__BACK_BUTTON), # back to page 1/5 + + # Now proceed forward again to the end + FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 1/5 + FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 2/5 + FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 3/5 + FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 4/5 + FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 5/5 + FlowStep(seed_views.SeedSignMessageConfirmAddressView, screen_return_value=0), + FlowStep(seed_views.SeedSignMessageSignedMessageQRView, screen_return_value=0), + FlowStep(MainMenuView), + ]) + + # Scenario 4: Load a long message without whitespace + self.controller.discard_seed(0) + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.SIGN_MESSAGE), + FlowStep(scan_views.ScanView, before_run=self.load_no_whitespace_message_into_decoder), # simulate read message QR; ret val is ignored + FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True), + FlowStep(seed_views.SeedSignMessageConfirmMessageView, before_run=self.inject_mesage_as_paged_message, screen_return_value=0), + FlowStep(seed_views.SeedSignMessageConfirmAddressView, screen_return_value=0), + FlowStep(seed_views.SeedSignMessageSignedMessageQRView, screen_return_value=0), + FlowStep(MainMenuView), + ]) + + + def test_sign_message_network_mismatch_flow(self): + """ + Should redirect to NetworkMismatchErrorView if a message's derivation path network doesn't match the current network. + + The error view should then forward to the Network Settings update View. + """ + # Ensure message signing is enabled + self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED) + + def expect_network_mismatch_error(load_message: Callable): + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=load_message), # simulate read message QR; ret val is ignored + FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True), + FlowStep(NetworkMismatchErrorView), + FlowStep(settings_views.SettingsEntryUpdateSelectionView), + ]) + + # MAINNET settings vs TESTNET derivation path with the message + self.settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.MAINNET) + expect_network_mismatch_error(self.load_testnet_message_into_decoder) + + # TESTNET settings vs MAINNET derivation path with the message + self.settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.TESTNET) + expect_network_mismatch_error(self.load_short_message_into_decoder) + + # REGTEST settings vs MAINNET derivation path with the message + self.settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.REGTEST) + expect_network_mismatch_error(self.load_short_message_into_decoder) + + + def test_sign_message_option_disabled(self): + """ + Should redirect to OptionDisabledView if a `signmessage` QR is scanned with + message signing disabled. + + Should offer the option to route directly to enable that settings or return to + MainMenuView. + """ + # Ensure message signing is disabled + self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__DISABLED) + + sequence = [ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=self.load_short_message_into_decoder), # simulate read message QR; ret val is ignored + FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True), + ] + + # First test routing to update the setting + self.run_sequence( + sequence + [ + FlowStep(OptionDisabledView, button_data_selection=OptionDisabledView.UPDATE_SETTING), + FlowStep(settings_views.SettingsEntryUpdateSelectionView), + ] + ) + + # Now test exiting to Main Menu + self.run_sequence( + sequence + [ + FlowStep(OptionDisabledView, button_data_selection=OptionDisabledView.DONE), + FlowStep(MainMenuView), + ] + ) + + + def test_sign_message_invalid_qr_flow(self): + """ + Should clear `Controller.resume_main_flow` and redirect to ErrorView if an + invalid signmessage QR is scanned. + + The error view should then forward to MainMenuView. + """ + # Ensure message signing is enabled + self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED) + + def load_invalid_signmessage_qr(view: scan_views.ScanView): + view.decoder.add_data("this text will not make sense to the decoder") + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.SIGN_MESSAGE), + FlowStep(scan_views.ScanView, before_run=load_invalid_signmessage_qr), # simulate read message QR; ret val is ignored + FlowStep(ErrorView), + FlowStep(MainMenuView), + ]) + + assert self.controller.resume_main_flow is None + + + def test_sign_message_unsupported_derivation_flow(self): + """ + Should redirect to NotYetImplementedView if a message's derivation path isn't yet supported + """ + # Ensure message signing is enabled + self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED) + + def expect_unsupported_derivation(load_message: Callable): + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.SIGN_MESSAGE), + FlowStep(scan_views.ScanView, before_run=load_message), # simulate read message QR; ret val is ignored + FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True), + FlowStep(seed_views.NotYetImplementedView), + FlowStep(MainMenuView), + ]) + + self.settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.MAINNET) + expect_unsupported_derivation(self.load_custom_derivation_into_decoder) + diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py new file mode 100644 index 00000000..c449f679 --- /dev/null +++ b/tests/test_flows_settings.py @@ -0,0 +1,138 @@ +import os +from typing import Callable + +from unittest.mock import PropertyMock, patch + +# Must import test base before the Controller +from base import FlowTest, FlowStep + +from seedsigner.models.settings import Settings +from seedsigner.models.settings_definition import SettingsDefinition, SettingsConstants +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON +from seedsigner.hardware.microsd import MicroSD +from seedsigner.views.view import MainMenuView +from seedsigner.views import scan_views, settings_views + + + +class TestSettingsFlows(FlowTest): + def test_persistent_settings(self): + """ Basic flow from MainMenuView to enable/disable persistent settings """ + # Which option are we testing? + settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS) + + # No settings file should exist before we enable persistent settings + assert os.path.exists(Settings.SETTINGS_FILENAME) == False + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), + FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name), + FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED)), + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(settings_views.SettingsMenuView), + ]) + + # Settings file should now exist + assert os.path.exists(Settings.SETTINGS_FILENAME) == True + + + def test_multiselect(self): + """ Multiselect Settings options should stay in-place; requires BACK to exit. """ + # Which option are we testing? + settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS) + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), + FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name), + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=0), # select/deselect first option + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK to exit + FlowStep(settings_views.SettingsMenuView), + ]) + + + def test_io_test(self): + """ Basic flow from MainMenuView to I/O Test View """ + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), + FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.IO_TEST), + FlowStep(settings_views.IOTestView), + FlowStep(settings_views.SettingsMenuView), + ]) + + + def test_donate(self): + """ Basic flow from MainMenuView to Donate View """ + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), + FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.DONATE), + FlowStep(settings_views.DonateView), + FlowStep(settings_views.SettingsMenuView), + ]) + + + def test_settingsqr(self): + """ + Scanning a SettingsQR should present the success screen and then return to + MainMenuView. + """ + def load_persistent_settingsqr_into_decoder(view: scan_views.ScanView): + settingsqr_data_persistent: str = "settings::v1 name=Total_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E" + view.decoder.add_data(settingsqr_data_persistent) + + def load_not_persistent_settingsqr_into_decoder(view: scan_views.ScanView): + settingsqr_data_not_persistent: str = "settings::v1 name=Ephemeral_noob_mode persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E" + view.decoder.add_data(settingsqr_data_not_persistent) + + def _run_test(initial_setting_state: str, load_settingsqr_into_decoder: Callable, expected_setting_state: str): + self.settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, initial_setting_state) + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=load_settingsqr_into_decoder), # simulate read message QR; ret val is ignored + FlowStep(settings_views.SettingsIngestSettingsQRView), # ret val is ignored + FlowStep(MainMenuView), + ]) + + assert self.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == expected_setting_state + + + # First load a SettingsQR that enables persistent settings + self.mock_microsd.is_inserted = True + assert MicroSD.get_instance().is_inserted is True + + _run_test( + initial_setting_state=SettingsConstants.OPTION__DISABLED, + load_settingsqr_into_decoder=load_persistent_settingsqr_into_decoder, + expected_setting_state=SettingsConstants.OPTION__ENABLED + ) + + # Then one that disables it + _run_test( + initial_setting_state=SettingsConstants.OPTION__ENABLED, + load_settingsqr_into_decoder=load_not_persistent_settingsqr_into_decoder, + expected_setting_state=SettingsConstants.OPTION__DISABLED + ) + + # Now try to enable persistent settings when the SD card is not inserted + self.mock_microsd.is_inserted = False + assert MicroSD.get_instance().is_inserted is False + + # Have to jump through some hoops to completely simulate the SD card being + # removed; we need Settings to restrict Persistent Settings to only allow + # DISABLED. + with patch('seedsigner.models.settings.Settings.HOSTNAME', new_callable=PropertyMock) as mock_hostname: + # Must identify itself as SeedSigner OS to trigger the SD card removal logic + mock_hostname.return_value = Settings.SEEDSIGNER_OS + Settings.handle_microsd_state_change(MicroSD.ACTION__REMOVED) + + selection_options = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS).selection_options + assert len(selection_options) == 1 + assert selection_options[0][0] == SettingsConstants.OPTION__DISABLED + assert self.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED + + _run_test( + initial_setting_state=SettingsConstants.OPTION__DISABLED, + load_settingsqr_into_decoder=load_persistent_settingsqr_into_decoder, + expected_setting_state=SettingsConstants.OPTION__DISABLED + ) diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py new file mode 100644 index 00000000..588859be --- /dev/null +++ b/tests/test_flows_tools.py @@ -0,0 +1,247 @@ +# Must import test base before the Controller +from base import FlowTest, FlowStep + +from seedsigner.controller import Controller +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON +from seedsigner.models.seed import Seed +from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition +from seedsigner.views.view import ErrorView, MainMenuView +from seedsigner.views import scan_views, seed_views, tools_views + + + +class TestToolsFlows(FlowTest): + + def test__address_explorer__flow(self): + """ + Test the simplest AddressExplorer flow when a seed is already loaded. + """ + controller = Controller.get_instance() + seed = Seed(mnemonic=["abandon "* 11 + "about"]) + controller.storage.set_pending_seed(seed) + controller.storage.finalize_pending_seed() + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER), + FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, screen_return_value=0), # ret 1st onboard seed + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(SettingsConstants.NATIVE_SEGWIT)), + FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE), + FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=10), # ret NEXT page of addrs + FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=4), # ret a specific addr from the list + FlowStep(tools_views.ToolsAddressExplorerAddressView), # runs until dismissed; no ret value + FlowStep(tools_views.ToolsAddressExplorerAddressListView), + ]) + + + def test__address_explorer__loadseed__sideflow(self): + """ + Finalizing a seed during the Address Explorer flow should return to the next + Address Explorer step upon completion. + """ + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("0000" * 11 + "0003") + + # Finalize the new seed w/out passphrase + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER), + FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED), + FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder), # simulate read SeedQR + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView), + ]) + + assert self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER + + # Reset + self.controller.storage.seeds.clear() + self.controller.storage.set_pending_seed(Seed(mnemonic=["abandon "* 11 + "about"])) + + # Finalize the new seed w/passphrase + self.run_sequence( + sequence=[ + 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), + FlowStep(seed_views.SeedExportXpubScriptTypeView), + ] + ) + + + def test__address_explorer__load_electrum_seed__sideflow(self): + """ + Loading an Electrum seed during the Address Explorer flow should return to + the Address Explorer flow upon completion, skip the script type selection, + and successfully generate receive or change addresses. + """ + self.settings.set_value(SettingsConstants.SETTING__ELECTRUM_SEEDS, SettingsConstants.OPTION__ENABLED) + + sequence = [ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER), + FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.TYPE_ELECTRUM), + FlowStep(seed_views.SeedElectrumMnemonicStartView), + ] + + # Load an Electrum mnemonic during the flow (same one used in test_seed.py) + for word in "regular reject rare profit once math fringe chase until ketchup century escape".split(): + sequence += [ + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word), + ] + + sequence += [ + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True), + FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE), + FlowStep(tools_views.ToolsAddressExplorerAddressListView), + ] + + self.run_sequence(sequence) + + + + def test__address_explorer__scan_wrong_qrtype__flow(self): + """ + Scanning the wrong type of QR code when a SeedQR is expected should route to ErrorView + """ + def load_wrong_data_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq") + + # Finalize the new seed w/out passphrase + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER), + FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED), + FlowStep(scan_views.ScanSeedQRView, before_run=load_wrong_data_into_decoder), # simulate scanning the wrong QR type + FlowStep(ErrorView), + ]) + + + def test__address_explorer__back_button__flow(self): + """ + Backing out of AddressExplorer behavior depends on current Settings: + * Multiple script types enabled: BACK to SeedExportXpubScriptTypeView + * One script type enabled: BACK to where we started: + * SeedOptions + * ToolsAddressExplorerSelectSourceView if seed was already onboard + * MainMenu if no seed was onboard when we entered via ToolsMenu (loading a + seed during the flow wipes out any history before the load so our only + option is to return to MainMenu). + """ + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("0000" * 11 + "0003") + + controller = Controller.get_instance() + seed = Seed(mnemonic=["abandon "* 11 + "about"]) + controller.storage.set_pending_seed(seed) + controller.storage.finalize_pending_seed() + + # Scenario 1: Seed already onboard, multiple script types enabled, BACK can still + # change script type selection. + self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [SettingsConstants.NATIVE_SEGWIT, SettingsConstants.TAPROOT]) + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), + FlowStep(seed_views.SeedsMenuView, screen_return_value=0), # select the first onboard seed + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPLORER), + FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=0), + FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(seed_views.SeedExportXpubScriptTypeView), + ]) + + # Scenario 2: Seed already onboard, one script type enabled, started from + # SeedOptionsView, BACK to SeedOptionsView. + self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [SettingsConstants.NATIVE_SEGWIT]) + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), + FlowStep(seed_views.SeedsMenuView, screen_return_value=0), # select the first onboard seed + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPLORER), + FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True), + FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(seed_views.SeedOptionsView), + ]) + + # Scenario 3: Seed already onboard, one script type enabled, started from + # ToolsMenu, BACK to ToolsAddressExplorerSelectSourceView. + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER), + FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, screen_return_value=0), # select the first onboard seed + FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True), + FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(tools_views.ToolsAddressExplorerSelectSourceView), + ]) + + # Scenario 4: No seed onboard, one script type enabled, started from Tools, BACK + # can only go to MainMenu because of mid-flow seed load. + controller.discard_seed(0) + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER), + FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED), + FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder), # simulate read SeedQR + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True), + FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(MainMenuView), + ]) + + + def test__address_explorer__legacy_multisig_p2sh__flow(self): + """ + Address Explorer should be able to parse a legacy multisig p2sh (m/45') + descriptor and generate addresses. + """ + def load_descriptor_into_decoder(view: scan_views.ScanView): + # descriptor from test_psbt_parser.py + p2sh_descriptor = "sh(sortedmulti(2,[0f889044/45h]tpubD8NkS3Gngj7L4FJRYrwojKhsx2seBhrNrXVdvqaUyvtVe1YDCVcziZVa9g3KouXz7FN5CkGBkoC16nmNu2HcG9ubTdtCbSW8DEXSMHmmu62/<0;1>/*,[03cd0a2b/45h]tpubD8HkLLgkdJkVitn1i9CN4HpFKJdom48iKm9PyiXYz5hivn1cGz6H3VeS6ncmCEgamvzQA2Qofu2YSTwWzvuaYWbJDEnvTUtj5R96vACdV6L/<0;1>/*,[769f695c/45h]tpubD98hRDKvtATTM8hy5Vvt5ZrvDXwJvrUZm1p1mTKDmd7FqUHY9Wj2k4X1CvxjjtTf3JoChWqYbnWjfkRJ65GQnpVJKbbMfjnGzCwoBUXafyM/<0;1>/*))#uardwtq4".replace("<0;1>", "{0,1}") + view.decoder.add_data(p2sh_descriptor) + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER), + FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_DESCRIPTOR), + FlowStep(scan_views.ScanWalletDescriptorView, before_run=load_descriptor_into_decoder), # simulate read descriptor QR + FlowStep(seed_views.MultisigWalletDescriptorView, button_data_selection=seed_views.MultisigWalletDescriptorView.ADDRESS_EXPLORER), + FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE), + FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=10), # ret NEXT page of addrs + FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=4), # ret a specific addr from the list + FlowStep(tools_views.ToolsAddressExplorerAddressView), # runs until dismissed; no ret value + FlowStep(tools_views.ToolsAddressExplorerAddressListView), + ]) + + + def test__verify_address__legacy_multisig_p2sh__flow(self): + """ + Address Explorer should be able to scan a legacy multisig p2sh address and + verify it against its descriptor. + """ + def load_address_into_decoder(view: scan_views.ScanView): + # Receive addr @ index 5 from test_psbt_parser.py + view.decoder.add_data("2N5eN5vUpgsLHAGzKm2VfmYyvNwXmCug5dH") + + def load_descriptor_into_decoder(view: scan_views.ScanView): + # descriptor from test_psbt_parser.py + p2sh_descriptor = "sh(sortedmulti(2,[0f889044/45h]tpubD8NkS3Gngj7L4FJRYrwojKhsx2seBhrNrXVdvqaUyvtVe1YDCVcziZVa9g3KouXz7FN5CkGBkoC16nmNu2HcG9ubTdtCbSW8DEXSMHmmu62/<0;1>/*,[03cd0a2b/45h]tpubD8HkLLgkdJkVitn1i9CN4HpFKJdom48iKm9PyiXYz5hivn1cGz6H3VeS6ncmCEgamvzQA2Qofu2YSTwWzvuaYWbJDEnvTUtj5R96vACdV6L/<0;1>/*,[769f695c/45h]tpubD98hRDKvtATTM8hy5Vvt5ZrvDXwJvrUZm1p1mTKDmd7FqUHY9Wj2k4X1CvxjjtTf3JoChWqYbnWjfkRJ65GQnpVJKbbMfjnGzCwoBUXafyM/<0;1>/*))#uardwtq4".replace("<0;1>", "{0,1}") + view.decoder.add_data(p2sh_descriptor) + + settings = Controller.get_instance().settings + settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.REGTEST) + + 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.AddressVerificationSigTypeView, button_data_selection=seed_views.AddressVerificationSigTypeView.MULTISIG), + FlowStep(seed_views.LoadMultisigWalletDescriptorView, button_data_selection=seed_views.LoadMultisigWalletDescriptorView.SCAN), + FlowStep(scan_views.ScanWalletDescriptorView, before_run=load_descriptor_into_decoder), # simulate read descriptor QR + FlowStep(seed_views.MultisigWalletDescriptorView, screen_return_value=0), + FlowStep(seed_views.SeedAddressVerificationView), + FlowStep(seed_views.AddressVerificationSuccessView), + ]) + diff --git a/tests/test_flows_view.py b/tests/test_flows_view.py new file mode 100644 index 00000000..08aa8a41 --- /dev/null +++ b/tests/test_flows_view.py @@ -0,0 +1,74 @@ +from unittest.mock import patch + +# Must import test base before the Controller +from base import FlowTest, FlowStep + +from seedsigner.gui.screens.screen import RET_CODE__POWER_BUTTON +from seedsigner.models.settings import Settings +from seedsigner.views.tools_views import ToolsCalcFinalWordNumWordsView, ToolsMenuView +from seedsigner.views.view import MainMenuView, NotYetImplementedView, PowerOptionsView, PowerOffView, RestartView, UnhandledExceptionView, View + + + +class TestViewFlows(FlowTest): + + def test_restart_flow(self): + """ + Basic flow from MainMenuView to RestartView + """ + with patch('seedsigner.views.view.RestartView.DoResetThread'): + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), + FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.RESET), + FlowStep(RestartView), + ]) + + + def test_power_off_flow(self): + """ + Basic flow from MainMenuView to PowerOffView + """ + with patch('seedsigner.views.view.PowerOffView.PowerOffThread'): + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), + FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), + FlowStep(PowerOffView), + ]) + + # And again, but this time as if we were in the SeedSigner OS + Settings.HOSTNAME = Settings.SEEDSIGNER_OS + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), + FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), + FlowStep(PowerOffView), # returns BackStackView + FlowStep(PowerOptionsView), + ]) + + + def test_not_yet_implemented_flow(self): + """ + Run an incomplete View that returns None and ensure that we get the NotYetImplementedView + """ + class IncompleteView(View): + def run(self): + self.run_screen(None) + return None + + self.run_sequence([ + FlowStep(IncompleteView), + FlowStep(NotYetImplementedView), + FlowStep(MainMenuView), + ]) + + + def test_unhandled_exception_flow(self): + """ + Basic flow from any arbitrary View to the UnhandledExceptionView + """ + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD), + FlowStep(ToolsCalcFinalWordNumWordsView, screen_return_value=Exception("Test exception")), # <-- force an exception + FlowStep(UnhandledExceptionView), + FlowStep(MainMenuView), + ]) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 00000000..94029807 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,50 @@ +import logging +import pytest +import sys +from unittest.mock import patch, call + +# Must import from base.py before any other SeedSigner dependency to mock out certain imports +from base import BaseTest + +sys.path.insert(0,'src') +from main import main + + +class TestMain(BaseTest): + """ + The `main.Controller` has to be patched / mocked out otherwise the virtual SeedSigner + will just keep running and cause the test to hang. + """ + @patch("main.Controller") + def test_main__argparse__default(self, patched_controller): + main([]) + assert logging.root.level == logging.INFO + assert logging.getLogger().getEffectiveLevel() == logging.INFO + patched_controller.assert_has_calls( + [call.get_instance(), call.get_instance().start()] + ) + + + @patch("main.Controller") + def test_main__argparse__enable_debug_logging(self, patched_controller): + main(["--loglevel", "DEBUG"]) + assert logging.root.level == logging.DEBUG + assert logging.getLogger().getEffectiveLevel() == logging.DEBUG + patched_controller.assert_has_calls( + [call.get_instance(), call.get_instance().start()] + ) + + + def test_main__argparse__invalid_arg(self): + with pytest.raises(SystemExit): + main(["--invalid"]) + + + @patch("main.Controller") + def test_main__logging__writes_to_stderr(self, patched_controller, capsys): + main([]) + _, err = capsys.readouterr() + assert "Starting SeedSigner" in err and "INFO" in err + patched_controller.assert_has_calls( + [call.get_instance(), call.get_instance().start()] + ) \ No newline at end of file diff --git a/tests/test_mnemonic_generation.py b/tests/test_mnemonic_generation.py index a0c04ed8..a1f46e85 100644 --- a/tests/test_mnemonic_generation.py +++ b/tests/test_mnemonic_generation.py @@ -1,3 +1,4 @@ +import pytest import random from embit import bip39 @@ -11,16 +12,17 @@ def test_dice_rolls(): dice_rolls = "" for i in range(0, 99): # Do not need truly rigorous random for this test - dice_rolls += str(random.randint(0, 5)) + dice_rolls += str(random.randint(1, 6)) mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls) + assert len(mnemonic) == 24 assert bip39.mnemonic_is_valid(" ".join(mnemonic)) dice_rolls = "" - for i in range(0, 50): + for i in range(0, mnemonic_generation.DICE__NUM_ROLLS__12WORD): # Do not need truly rigorous random for this test - dice_rolls += str(random.randint(0, 5)) + dice_rolls += str(random.randint(1, 6)) mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls) assert len(mnemonic) == 12 @@ -28,18 +30,69 @@ def test_dice_rolls(): -def test_calculate_checksum(): - """ Given an 11-word or 23-word mnemonic, the calculated checksum should yield a +def test_calculate_checksum_input_type(): + """ + Given an 11-word or 23-word mnemonic, the calculated checksum should yield a valid complete mnemonic. + + calculate_checksum should accept the mnemonic as: + * a list of strings + * string: "A B C", "A, B, C", "A,B,C" """ # Test mnemonics from https://iancoleman.io/bip39/ + def _try_all_input_formats(partial_mnemonic: str): + # List of strings + mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" ")) + assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + + # Comma-separated string + mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.replace(" ", ",")) + assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + + # Comma-separated string w/space + mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.replace(" ", ", ")) + assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + + # Space-separated string + mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic) + assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + partial_mnemonic = "crawl focus rescue cable view pledge rather dinner cousin unfair day" - mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) - assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + _try_all_input_formats(partial_mnemonic) partial_mnemonic = "bubble father debate ankle injury fence mesh evolve section wet coyote violin pyramid flower rent arrow round clutch myth safe base skin mobile" - mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) - assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + _try_all_input_formats(partial_mnemonic) + + + + +def test_calculate_checksum_invalid_mnemonics(): + """ + Should raise an Exception on a mnemonic that is invalid due to length or using invalid words. + """ + with pytest.raises(Exception) as e: + # Mnemonic is too short: 10 words instead of 11 + partial_mnemonic = "abandon " * 9 + "about" + mnemonic_generation.calculate_checksum(partial_mnemonic) + assert "12- or 24-word" in str(e) + + with pytest.raises(Exception) as e: + # Valid mnemonic but unsupported length + mnemonic = "devote myth base logic dust horse nut collect buddy element eyebrow visit empty dress jungle" + mnemonic_generation.calculate_checksum(mnemonic) + assert "12- or 24-word" in str(e) + + with pytest.raises(Exception) as e: + # Mnemonic is too short: 22 words instead of 23 + partial_mnemonic = "abandon " * 21 + "about" + mnemonic_generation.calculate_checksum(partial_mnemonic) + assert "12- or 24-word" in str(e) + + with pytest.raises(ValueError) as e: + # Invalid BIP-39 word + partial_mnemonic = "foobar " * 11 + "about" + mnemonic_generation.calculate_checksum(partial_mnemonic) + assert "not in the dictionary" in str(e) @@ -48,20 +101,36 @@ def test_calculate_checksum_with_default_final_word(): the mnemonic. """ partial_mnemonic = "crawl focus rescue cable view pledge rather dinner cousin unfair day" - mnemonic1 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic1 = mnemonic_generation.calculate_checksum(partial_mnemonic) partial_mnemonic += " abandon" - mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic) assert mnemonic1 == mnemonic2 partial_mnemonic = "bubble father debate ankle injury fence mesh evolve section wet coyote violin pyramid flower rent arrow round clutch myth safe base skin mobile" - mnemonic1 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic1 = mnemonic_generation.calculate_checksum(partial_mnemonic) partial_mnemonic += " abandon" - mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic) assert mnemonic1 == mnemonic2 +def test_generate_mnemonic_from_bytes(): + """ + Should generate a valid BIP-39 mnemonic from entropy bytes + """ + # From iancoleman.io + entropy = "3350f6ac9eeb07d2c6209932808aa7f6" + expected_mnemonic = "crew marble private differ race truly blush basket crater affair prepare unique".split() + mnemonic = mnemonic_generation.generate_mnemonic_from_bytes(bytes.fromhex(entropy)) + assert mnemonic == expected_mnemonic + + entropy = "5bf41629fce815c3570955e8f45422abd7e2234141bd4d7ec63b741043b98cad" + expected_mnemonic = "fossil pass media what life ticket found click trophy pencil anger fish lawsuit balance agree dash estate wage mom trial aerobic system crawl review".split() + mnemonic = mnemonic_generation.generate_mnemonic_from_bytes(bytes.fromhex(entropy)) + assert mnemonic == expected_mnemonic + + def test_verify_against_coldcard_sample(): """ https://coldcard.com/docs/verifying-dice-roll-math """ diff --git a/tests/test_psbt_parser.py b/tests/test_psbt_parser.py index 7fb84c60..474d671b 100644 --- a/tests/test_psbt_parser.py +++ b/tests/test_psbt_parser.py @@ -1,8 +1,203 @@ -import pytest -from mock import MagicMock -from seedsigner.models import PSBTParser +from binascii import a2b_base64 from embit import psbt +from embit.descriptor import Descriptor -def test_sig_count(): - print("test") - \ No newline at end of file +from seedsigner.models.psbt_parser import PSBTParser +from seedsigner.models.seed import Seed +from seedsigner.models.settings_definition import SettingsConstants + + + +def test_p2tr_change_detection(): + """ Should successfully detect change in a p2tr to p2tr psbt spend + + PSBT Tx and Wallet Details + - Single Sig Wallet P2TR (Taproot) with no passphrase + - Regtest 394aed14 m/86'/1'/0' tpubDCawGrRg7YdHdFb9p4mmD8GBaZjJegL53FPFRrMkGoLcgLATJfksUs2y1Q7dVzixAkgecazsxEsUuyj3LyDw7eVVYHQyojwrc2hfesK4wXW + - 1 Inputs + - 3,190,493,401 sats + - 2 Outputs + - 1 Output spend to another wallet (bcrt1p6p00wazu4nnqac29fvky6vhjnnhku5u2g9njss62rvy7e0yuperq86f5ek) p2tr address + - 1 Output change + - addresss bcrt1prz4g6saush37epdwhvwpu78td3q7yfz3xxz37axlx7udck6wracq3rwq30) + - amount 2,871,443,918 sats + - Change addresses is index 1/1 + - Fee 155 sats + """ + + psbt_base64 = "cHNidP8BAIkCAAAAAf8upuiIWF1VTgC/Q8ZWRrameRigaXpRcQcBe8ye+TK3AQAAAAAXCgAAAs7BJqsAAAAAIlEgGKqNQ7yF4+yFrrscHnjrbEHiJFExhR903ze43FtOH3BwTgQTAAAAACJRINBe93RcrOYO4UVLLE0y8pzvblOKQWcoQ0obCey8nA5GAAAAAE8BBDWHzwNMUx9OgAAAAJdr+WtwWfVa6IPbpKZ4KgRC0clbm11Gl155IPA27n2FAvQCrFGH6Ac2U0Gcy1IH5f5ltgUBDz2+fe8iqL6JzZdgEDlK7RRWAACAAQAAgAAAAIAAAQB9AgAAAAGAKOOUFIzw9pbRDaZ7F0DYhLImrdMn//OSm++ff5VNdAAAAAAAAQAAAAKsjLwAAAAAABYAFKEcuxvXmB3rWHSqSviP5mrKMZoL2RArvgAAAAAiUSBGU0Lg5fx/ECsB1Z4ZUqXQFSLFnlmpm0rm5R2l599h2AAAAAABASvZECu+AAAAACJRIEZTQuDl/H8QKwHVnhlSpdAVIsWeWambSublHaXn32HYAQMEAAAAACEWF7hZVn7pIDR429kAn/WDeQiWjZey1iGHztsL1H83QLMZADlK7RRWAACAAQAAgAAAAIABAAAAAAAAAAEXIBe4WVZ+6SA0eNvZAJ/1g3kIlo2XstYhh87bC9R/N0CzACEHbJdqWyMxF2eOPr6YRXUJmry04HUbgKyeM2IZeG+NI9AZADlK7RRWAACAAQAAgAAAAIABAAAAAQAAAAEFIGyXalsjMRdnjj6+mEV1CZq8tOB1G4CsnjNiGXhvjSPQAAA=" + + raw = a2b_base64(psbt_base64) + tx = psbt.PSBT.parse(raw) + + mnemonic = "goddess rough corn exclude cream trial fee trumpet million prevent gaze power".split() + pw = "" + seed = Seed(mnemonic, passphrase=pw) + + pp = PSBTParser(p=tx, seed=seed, network=SettingsConstants.REGTEST) + + assert pp.change_data == [ + { + 'output_index': 0, + 'address': 'bcrt1prz4g6saush37epdwhvwpu78td3q7yfz3xxz37axlx7udck6wracq3rwq30', + 'amount': 2871443918, + 'fingerprint': ['394aed14'], + 'derivation_path': ['m/86h/1h/0h/1/1']} + ] + assert pp.spend_amount == 319049328 + assert pp.change_amount == 2871443918 + assert pp.destination_addresses == ['bcrt1p6p00wazu4nnqac29fvky6vhjnnhku5u2g9njss62rvy7e0yuperq86f5ek'] + assert pp.destination_amounts == [319049328] + + + +def test_p2sh_legacy_multisig(): + """ + Should correctly parse a legacy multisig p2sh (m/45') psbt. + + PSBT Tx, wallet, and keys + - Legacy 2-of-3 multisig p2sh; same format as Unchained + - Regtest xpubs: + - 0f889044 m/45' tpubD8NkS3Gngj7L4FJRYrwojKhsx2seBhrNrXVdvqaUyvtVe1YDCVcziZVa9g3KouXz7FN5CkGBkoC16nmNu2HcG9ubTdtCbSW8DEXSMHmmu62 (aka "Zoe" test seed) + - 03cd0a2b m/45' tpubD8HkLLgkdJkVitn1i9CN4HpFKJdom48iKm9PyiXYz5hivn1cGz6H3VeS6ncmCEgamvzQA2Qofu2YSTwWzvuaYWbJDEnvTUtj5R96vACdV6L (aka "Malcolm" test seed) + - 769f695c m/45' tpubD98hRDKvtATTM8hy5Vvt5ZrvDXwJvrUZm1p1mTKDmd7FqUHY9Wj2k4X1CvxjjtTf3JoChWqYbnWjfkRJ65GQnpVJKbbMfjnGzCwoBUXafyM (aka "Unchained" test seed) + + - 2 Inputs + - 199,661 sats + - 3 Outputs + - 1 Output spend to another wallet: 50,000 sats to bcrt1q8q5uk9z7ta08h8hvknysd5n80w6f7kuvk5ey2m + - 1 Output internal self-cycle + - addresss 2N5eN5vUpgsLHAGzKm2VfmYyvNwXmCug5dH + - amount 90,000 sats + - receive address is index 0/5 + - 1 Output change + - addresss 2NEnA5emHw9Q6vHXr912hGMSPtnrwAMReLz) + - amount 58,969 sats + - change addresses is index 1/0 + - Fee 692 sats + + "Malcolm": better gown govern speak spawn vendor exercise item uncle odor sound cat + "Zoe": sign sword lift deer ocean insect web lazy sick pencil start select + "Unchained": slight affair prefer tenant vacant below drill govern surface science affair nut + + """ + descriptor = Descriptor.from_string("sh(sortedmulti(2,[0f889044/45h]tpubD8NkS3Gngj7L4FJRYrwojKhsx2seBhrNrXVdvqaUyvtVe1YDCVcziZVa9g3KouXz7FN5CkGBkoC16nmNu2HcG9ubTdtCbSW8DEXSMHmmu62/<0;1>/*,[03cd0a2b/45h]tpubD8HkLLgkdJkVitn1i9CN4HpFKJdom48iKm9PyiXYz5hivn1cGz6H3VeS6ncmCEgamvzQA2Qofu2YSTwWzvuaYWbJDEnvTUtj5R96vACdV6L/<0;1>/*,[769f695c/45h]tpubD98hRDKvtATTM8hy5Vvt5ZrvDXwJvrUZm1p1mTKDmd7FqUHY9Wj2k4X1CvxjjtTf3JoChWqYbnWjfkRJ65GQnpVJKbbMfjnGzCwoBUXafyM/<0;1>/*))#uardwtq4".replace("<0;1>", "{0,1}")) + psbt_base64 = "cHNidP8BALsCAAAAAk/6v0Yo0tvQSd45NaCoZQj0dS2RU35cF+KXp/RbBltsAAAAAAD9////HN9jZsT3CVXquPrSgGg7/H8DHsy18Ej8uCqaAo8UAsQAAAAAAP3///8DWeYAAAAAAAAXqRTsNEZFrVtk15AU60/MeTWjxGCZJIeQXwEAAAAAABepFIgB1fOQz3ajeGClCsf7Kn4BDG1Zh1DDAAAAAAAAFgAUOCnLFF5fXnue7LTJBtJne7SfW4xlCgAATwEENYfPAQPNCiuAAAAtoPXmwca4wIkJmJbT0l8IJkQoZyf1a0Hf3l3/y+P9YLsCb3zYh0WQQHK0NeKTHOh4tXmreSkeD5t+ayaPudyvWWAIA80KKy0AAIBPAQQ1h88BD4iQRIAAAC1xQDAuEKWgk+mzBHCEZ3Ibco/WRjRUB61ToV0CY2upCgMoWAP8JdgKLlkerHgciZglm2jGmPHrQqLuS8rgRqfwWQgPiJBELQAAgE8BBDWHzwF2n2lcgAAALXtkfUG4BFcO0mnNEFWpGBBvebmUn9Icjd9KVpKJF/MkA59Hw6Sxmpk0lp7SYIoBZJ8BFT3IVY9Ywu6NVn2JGfLmCHafaVwtAACAAAEAUwIAAAABLEtmpDrExA4GJ2itUuWqHQqVsr0WoamuwxKxFA+if3oDAAAAAP3///8BvIUBAAAAAAAXqRSO3FlqUGy1+B6q4UZU1uvY6aDX7YdkCgAAAQMEAQAAAAEEaVIhAhV0XDrvBSAO2pnyRtuyioVgPwb9fxQ7GwNSYKODA6XIIQKHsTdUi0B81JZaK9WASeMWb1ad2snk9iPJ8KKYGJDS+CEC6k1h+lULPMlXOd0x4bIBUwpoTr30vFfoHqr3gSKmlnlTriIGAoexN1SLQHzUllor1YBJ4xZvVp3ayeT2I8nwopgYkNL4EAPNCistAACAAAAAAAQAAAAiBgLqTWH6VQs8yVc53THhsgFTCmhOvfS8V+geqveBIqaWeRAPiJBELQAAgAAAAAAEAAAAIgYCFXRcOu8FIA7amfJG27KKhWA/Bv1/FDsbA1Jgo4MDpcgQdp9pXC0AAIAAAAAABAAAAAABAFMCAAAAASxLZqQ6xMQOBidorVLlqh0KlbK9FqGprsMSsRQPon96BAAAAAD9////ATGGAQAAAAAAF6kU7vgoQJrHpHs0uEBUzW4ogkY3VmuHYwoAAAEDBAEAAAABBGlSIQJMzyIV0BhlIAdtCFRC0nWcJ+qiowFHgStyQvx/Ov9lYSECo3z9DGK1zjn25m1n8NHEoQlcNOnsnF5UA2khAfUhxTUhA9IpGx2/u34tqOV/jRErjSguk6uQK3L743i2LgKpXB+VU64iBgJMzyIV0BhlIAdtCFRC0nWcJ+qiowFHgStyQvx/Ov9lYRADzQorLQAAgAAAAAADAAAAIgYD0ikbHb+7fi2o5X+NESuNKC6Tq5ArcvvjeLYuAqlcH5UQD4iQRC0AAIAAAAAAAwAAACIGAqN8/Qxitc459uZtZ/DRxKEJXDTp7JxeVANpIQH1IcU1EHafaVwtAACAAAAAAAMAAAAAAQBpUiEC7j3OSch6J9P+ZAcOiGeZ4Be3wS4zjzXyU6EzwixfEqQhAxzm3beiYzYmSxMsG0XD5jHoUCvBVSJtRvw41z1X+eT/IQMdnm4JRBPcOlCFGPcpryOjWzlDynm6+8Va+rYxWV5cz1OuIgIDHZ5uCUQT3DpQhRj3Ka8jo1s5Q8p5uvvFWvq2MVleXM8QA80KKy0AAIABAAAAAAAAACICAxzm3beiYzYmSxMsG0XD5jHoUCvBVSJtRvw41z1X+eT/EA+IkEQtAACAAQAAAAAAAAAiAgLuPc5JyHon0/5kBw6IZ5ngF7fBLjOPNfJToTPCLF8SpBB2n2lcLQAAgAEAAAAAAAAAAAEAaVIhAoETdqS+0tZtmj0auNDI9SxxCmUw5Iq9JJjvWjrpPGOCIQKD7KrnsR4fGz0vM67hRh17r9WznwE4JfSEJxSdJMVopyEDvLJhv9fUi2uoUAQN9AQ7fYeUFJMa/iRw2jKBYDn04zpTriICAoPsquexHh8bPS8zruFGHXuv1bOfATgl9IQnFJ0kxWinEAPNCistAACAAAAAAAUAAAAiAgKBE3akvtLWbZo9GrjQyPUscQplMOSKvSSY71o66TxjghAPiJBELQAAgAAAAAAFAAAAIgIDvLJhv9fUi2uoUAQN9AQ7fYeUFJMa/iRw2jKBYDn04zoQdp9pXC0AAIAAAAAABQAAAAAA" + raw = a2b_base64(psbt_base64) + tx = psbt.PSBT.parse(raw) + + # 03cd0a2b test seed + mnemonic = "better gown govern speak spawn vendor exercise item uncle odor sound cat".split() + seed = Seed(mnemonic) + assert seed.get_fingerprint() == "03cd0a2b" + + psbt_parser = PSBTParser(p=tx, seed=seed, network=SettingsConstants.REGTEST) + + assert psbt_parser.spend_amount == 50000 + assert psbt_parser.change_amount == 90000 + 58969 + assert psbt_parser.fee_amount == 692 + + assert psbt_parser.destination_addresses == ['bcrt1q8q5uk9z7ta08h8hvknysd5n80w6f7kuvk5ey2m'] + assert psbt_parser.destination_amounts == [50000] + + assert psbt_parser.get_change_data(0)['address'] == '2NEnA5emHw9Q6vHXr912hGMSPtnrwAMReLz' + assert psbt_parser.get_change_data(0)["amount"] == 58969 + + assert psbt_parser.get_change_data(1)['address'] == '2N5eN5vUpgsLHAGzKm2VfmYyvNwXmCug5dH' + assert psbt_parser.get_change_data(1)["amount"] == 90000 + + # We should be able to verify the change addr + assert psbt_parser.verify_multisig_output(descriptor, 0) + + # And the self-transfer receive addr + assert psbt_parser.verify_multisig_output(descriptor, 1) + + +def test_p2sh_p2wpkh_nested_segwit(): + """ + Should correctly parse a nested segwit (m/49'/1'/0') psbt. + + PSBT Tx, wallet, and keys + - nested segwit single sig + - Regtest xpubs: + - c751dc07 c751dc07 tpubDDS23bf7c9mdfWpuvA61HHCYDusq25UtMNYsFagKPNMNWHSm8bvwmNNP2KSpivN3gQWAK8fhDFk3dzgoBn9rPoMncKxJuqNAv7sJMShbZ6i + + - 1 Inputs + - 149,009 sats + - 2 Outputs + - 1 Output spend to another wallet: 93,000 sats to tb1qs7mdpjq7g7zq46vvycr8d6udc7za726ut8har9krfxpnc7kr04gqmdy2e4 + - 1 Output change + - addresss 2Mz3MthXyM4YDjLPw1V4PAacKt4pD8Cz8N3) + - amount 55,832 sats + - change addresses is index 1/1 + - Fee 177 sats + + seed: goddess rough corn exclude cream trial fee trumpet million prevent gaze power + passphrase: test + + """ + + descriptor = Descriptor.from_string("sh(wpkh([c751dc07/49h/1h/0h]tpubDDS23bf7c9mdfWpuvA61HHCYDusq25UtMNYsFagKPNMNWHSm8bvwmNNP2KSpivN3gQWAK8fhDFk3dzgoBn9rPoMncKxJuqNAv7sJMShbZ6i/<0;1>/*))#7sn8gf37".replace("<0;1>", "{0,1}")) + psbt_base64 = "cHNidP8BAH4CAAAAAXfY5crHl+bXtTvKvdo2MaFQeIXw+P+3kzZwBRgw84lFAQAAAAD9////AhjaAAAAAAAAF6kUSop8lEmO4FB1AyV1GJe2bygA7ASHSGsBAAAAAAAiACCHttDIHkeECumMJgZ2643Hhd8rXFnv0ZbDSYM8esN9UIouEwBPAQQ1h88Dv3UWAIAAAACfHgAYuw3ODwXCSP0valI9edAB1t3EInR2TXkbOd+F+AJgmJs8XUkZD5zQAgd3+/ijOqVphlWUMzxDnRorBQYEgxDHUdwHMQAAgAEAAIAAAACAAAEBIBFGAgAAAAAAF6kU7ijES3iWT8u0+44/blPlLfh9WkyHAQMEAQAAAAEEFgAUX7JspW1r0gC+WkUHwGABJ8DU9f8iBgO1/adRC+r8XJ/bjnfdwk3740n0m8gE3+xN8GHsNrxDUxjHUdwHMQAAgAEAAIAAAACAAQAAAAAAAAAAAQAWABT8V9vY29XR8niVYdVSF9H4zRTAbiICArH6DjPShnzXiaAnc2BR1f61QQliH0BOhqAvksByf3e9GMdR3AcxAACAAQAAgAAAAIABAAAAAQAAAAAA" + raw = a2b_base64(psbt_base64) + tx = psbt.PSBT.parse(raw) + + # 03cd0a2b test seed + mnemonic = "goddess rough corn exclude cream trial fee trumpet million prevent gaze power".split() + seed = Seed(mnemonic=mnemonic, passphrase="test") + assert seed.get_fingerprint() == "c751dc07" + + psbt_parser = PSBTParser(p=tx, seed=seed, network=SettingsConstants.TESTNET) + + assert psbt_parser.spend_amount == 93000 + assert psbt_parser.change_amount == 55832 + assert psbt_parser.fee_amount == 177 + + assert psbt_parser.destination_addresses == ['tb1qs7mdpjq7g7zq46vvycr8d6udc7za726ut8har9krfxpnc7kr04gqmdy2e4'] + assert psbt_parser.destination_amounts == [93000] + + assert psbt_parser.get_change_data(0)['address'] == '2Mz3MthXyM4YDjLPw1V4PAacKt4pD8Cz8N3' + assert psbt_parser.get_change_data(0)["amount"] == 55832 + + +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 cbfa8964..4dd8af93 100644 --- a/tests/test_seed.py +++ b/tests/test_seed.py @@ -1,11 +1,10 @@ import pytest -from mock import MagicMock -from seedsigner.models import Seed -from embit import bip39 +from seedsigner.models.seed import InvalidSeedException, Seed, ElectrumSeed from seedsigner.models.settings import SettingsConstants +# TODO: Change TAB indents to SPACE def test_seed(): seed = Seed(mnemonic="obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash".split()) @@ -39,6 +38,48 @@ def test_seed(): # assert seed.passphrase == "test" - - - \ No newline at end of file +def test_electrum_seed(): + """ + ElectrumSeed should correctly parse a modern Electrum mnemonic. + """ + 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 + + +def test_electrum_mnemonic_format(): + """ + ElectrumSeed should reject mnemonics that are not 12 words long. + """ + with pytest.raises(InvalidSeedException): + ElectrumSeed(mnemonic=["regular"] * 11) + + with pytest.raises(InvalidSeedException): + ElectrumSeed(mnemonic=["regular"] * 13) + + with pytest.raises(InvalidSeedException): + ElectrumSeed(mnemonic=["regular"] * 24) + + +def test_electrum_seed_rejects_most_bip39_mnemonics(): + """ + ElectrumSeed should throw an exception for most BIP-39 mnemonics. + + There are 1/16 odds that a seed will be valid for both formats. + """ + # Most BIP-39 seeds should fail; test seeds generated by bitcoiner.guide + with pytest.raises(InvalidSeedException): + ElectrumSeed(mnemonic="pioneer divide volcano art victory family grow novel mandate bicycle senior adjust".split()) + + with pytest.raises(InvalidSeedException): + ElectrumSeed(mnemonic="gentle combine cool hamster ghost harvest gossip lend dismiss slam any toast".split()) + + with pytest.raises(InvalidSeedException): + ElectrumSeed(mnemonic="enough board blossom stamp fire buffalo digital solution sadness random number stone".split()) + + # This one is valid for both formats + mnemonic = "only gain spot output unknown craft simple cram absorb suggest ridge famous".split() + Seed(mnemonic) + ElectrumSeed(mnemonic) diff --git a/tests/test_seedqr.py b/tests/test_seedqr.py index 7e2d6385..d792683c 100644 --- a/tests/test_seedqr.py +++ b/tests/test_seedqr.py @@ -1,25 +1,24 @@ import os -import pyzbar from embit import bip39 from seedsigner.helpers.qr import QR -from seedsigner.helpers.ur2.bytewords import decode from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus -from seedsigner.models.encode_qr import EncodeQR +from seedsigner.models.encode_qr import SeedQrEncoder, CompactSeedQrEncoder from seedsigner.models.qr_type import QRType -from seedsigner.models.settings import SettingsConstants +from seedsigner.models.seed import Seed def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type): """ Helper method to re-run multiple variations of the same encode/decode test """ - print(entropy) - seed_phrase = bip39.mnemonic_from_bytes(entropy).split() - print(seed_phrase) - assert len(seed_phrase) == mnemonic_length + mnemonic = bip39.mnemonic_from_bytes(entropy).split() + assert len(mnemonic) == mnemonic_length + + if qr_type == QRType.SEED__SEEDQR: + e = SeedQrEncoder(mnemonic=mnemonic) + elif qr_type == QRType.SEED__COMPACTSEEDQR: + e = CompactSeedQrEncoder(mnemonic=mnemonic) - e = EncodeQR(seed_phrase=seed_phrase, qr_type=qr_type) data = e.next_part() - print(data) qr = QR() image = qr.qrimage( @@ -34,8 +33,7 @@ def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type): assert status == DecodeQRStatus.COMPLETE decoded_seed_phrase = decoder.get_seed_phrase() - print(decoded_seed_phrase) - assert seed_phrase == decoded_seed_phrase + assert mnemonic == decoded_seed_phrase diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 00000000..94ac626a --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,106 @@ +import pytest +from base import BaseTest +from seedsigner.models.settings import InvalidSettingsQRData, Settings +from seedsigner.models.settings_definition import SettingsConstants + + + +class TestSettings(BaseTest): + @classmethod + def setup_class(cls): + super().setup_class() + cls.settings = Settings.get_instance() + + + def test_reset_settings(self): + """ BaseTest.reset_settings() should wipe out any previous Settings changes """ + settings = Settings.get_instance() + settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, SettingsConstants.OPTION__ENABLED) + assert settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED + + BaseTest.reset_settings() + settings = Settings.get_instance() + assert settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED + + + def test_parse_settingsqr_data(self): + """ + SettingsQR parser should successfully parse a valid settingsqr input string and + return the resulting config_name and formatted settings_update_dict. + """ + settings_name = "Test SettingsQR" + settingsqr_data = f"""settings::v1 name={ settings_name.replace(" ", "_") } persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss,ms scripts=nat,nes,tr xpub_details=E passphrase=E camera=180 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E""" + + # First explicitly set settings that differ from the settingsqr_data + self.settings.set_value(SettingsConstants.SETTING__COMPACT_SEEDQR, SettingsConstants.OPTION__DISABLED) + self.settings.set_value(SettingsConstants.SETTING__DIRE_WARNINGS, SettingsConstants.OPTION__DISABLED) + self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [SettingsConstants.COORDINATOR__BLUE_WALLET, SettingsConstants.COORDINATOR__SPARROW]) + + # Now parse the settingsqr_data + config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data) + assert config_name == settings_name + self.settings.update(new_settings=settings_update_dict) + + # Now verify that the settings were updated correctly + assert self.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__ENABLED + assert self.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED + + coordinators = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS) + assert SettingsConstants.COORDINATOR__BLUE_WALLET not in coordinators + assert SettingsConstants.COORDINATOR__SPARROW in coordinators + assert SettingsConstants.COORDINATOR__SPECTER_DESKTOP in coordinators + + + def test_settingsqr_version(self): + """ SettingsQR parser should accept SettingsQR v1 and reject any others """ + settingsqr_data = "settings::v1 name=Foo" + config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data) + + # Accepts update with no Exceptions + self.settings.update(new_settings=settings_update_dict) + + settingsqr_data = "settings::v2 name=Foo" + with pytest.raises(InvalidSettingsQRData) as e: + Settings.parse_settingsqr(settingsqr_data) + assert "Unsupported SettingsQR version" in str(e.value) + + # Should also fail if version omitted + settingsqr_data = "settings name=Foo" + with pytest.raises(InvalidSettingsQRData) as e: + Settings.parse_settingsqr(settingsqr_data) + + # And if "settings" is omitted entirely + settingsqr_data = "name=Foo" + with pytest.raises(InvalidSettingsQRData) as e: + Settings.parse_settingsqr(settingsqr_data) + + + def test_settingsqr_ignores_unrecognized_setting(self): + """ SettingsQR parser should ignore unrecognized settings """ + settingsqr_data = "settings::v1 name=Foo favorite_food=bacon xpub_export=D" + config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data) + + assert "favorite_food" not in settings_update_dict + assert "xpub_export" in settings_update_dict + + # Accepts update with no Exceptions + self.settings.update(new_settings=settings_update_dict) + + + def test_settingsqr_fails_unrecognized_option(self): + """ SettingsQR parser should fail if a settings has an unrecognized option """ + settingsqr_data = "settings::v1 name=Foo xpub_export=Yep" + with pytest.raises(InvalidSettingsQRData) as e: + Settings.parse_settingsqr(settingsqr_data) + assert "xpub_export" in str(e.value) + + + def test_settingsqr_parses_line_break_separators(self): + """ SettingsQR parser should read line breaks as acceptable separators """ + settingsqr_data = "settings::v1\nname=Foo\nsigs=ss,ms\nscripts=nat,nes,tr\nxpub_export=E\n" + config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data) + + assert len(settings_update_dict.keys()) == 3 + + # Accepts update with no Exceptions + self.settings.update(new_settings=settings_update_dict) diff --git a/tests/test_settingsqr_decoder.py b/tests/test_settingsqr_decoder.py new file mode 100644 index 00000000..bb124c4f --- /dev/null +++ b/tests/test_settingsqr_decoder.py @@ -0,0 +1,29 @@ +from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus + + + +class TestSettingsQRDecoder: + def test_decode_settingsqr(self): + """ + Assume the QR reader decodes the SettingsQR content correctly and begin this test + with parsing the result. + """ + settings_name = "Test SettingsQR" + settings_qr_str = f"""settings::v1 name={ settings_name.replace(" ", "_") } persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss,ms scripts=nat,nes,tr xpub_details=E passphrase=E camera=180 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E""" + + # Now parse the settings_qr_str + decoder = DecodeQR() + status = decoder.add_data(settings_qr_str) + assert decoder.is_settings + assert status == DecodeQRStatus.COMPLETE + + data = decoder.get_settings_data() + assert data == settings_qr_str + + + def test_settingsqr_version(self): + """ Should fail if the "settings" header is missing """ + settings_qr_str = "name=Foo" + decoder = DecodeQR() + status = decoder.add_data(settings_qr_str) + assert status == DecodeQRStatus.INVALID diff --git a/tools/mnemonic.py b/tools/mnemonic.py new file mode 100644 index 00000000..28490e94 --- /dev/null +++ b/tools/mnemonic.py @@ -0,0 +1,169 @@ +import argparse +import random +from seedsigner.helpers import mnemonic_generation +from embit.wordlists.bip39 import WORDLIST as WORDLIST__ENGLISH + +""" +see: docs/dice_verification.md (the "Command Line Tool" section) for full instructions. + +tldr: + pip3 install embit + pip3 install -e . + cd tools + python3 mnemonic.py -h +""" + + +usage = f""" +Verify SeedSigner's dice rolls and coin flip entropy-to-mnemonic conversion via this tool. + +Compare its results against iancoleman.io/bip39 and bitcoiner.guide/seed + +Usage: + # {mnemonic_generation.DICE__NUM_ROLLS__12WORD} dice rolls / 12-word mnemonic + python3 mnemonic.py dice 5624433434... + + # {mnemonic_generation.DICE__NUM_ROLLS__24WORD} dice rolls / 24-word mnemonic + python3 mnemonic.py dice 6151463561... + + # {mnemonic_generation.DICE__NUM_ROLLS__12WORD} dice rolls, entered as 0-5 / 12-word mnemonic + python3 mnemonic.py --zero-indexed-dice dice 5135535514... + + # 128 coin flips / 12-word mnemonic + python3 mnemonic.py coins 1111100111... + + # 256 coin flips / 24-word mnemonic + python mnemonic.py coins 0010111010... + + # GENERATE {mnemonic_generation.DICE__NUM_ROLLS__12WORD} random dice rolls / 12-word mnemonic + python3 mnemonic.py dice rand12 + + # GENERATE {mnemonic_generation.DICE__NUM_ROLLS__24WORD} random dice rolls / 24-word mnemonic + python3 mnemonic.py dice rand24 + + # GENERATE {mnemonic_generation.DICE__NUM_ROLLS__24WORD} random dice rolls, entered as 0-5 / 24-word mnemonic + python3 mnemonic.py --zero-indexed-dice dice rand24 + + # GENERATE 128 random coin flips / 12-word mnemonic + python3 mnemonic.py coins rand12 + + # GENERATE 256 random coin flips / 24-word mnemonic + python3 mnemonic.py coins rand24 +""" +RAND_12 = "rand12" +RAND_24 = "rand24" +parser = argparse.ArgumentParser(description=f'SeedSigner entropy-to-mnemonic tool\n\n{usage}', formatter_class=argparse.RawTextHelpFormatter) + +# Required positional arguments +parser.add_argument('method', type=str, choices=['dice', 'coins', 'final_word'], help="Input entropy method") +parser.add_argument('entropy', type=str, help=f"""Entropy data. Enter "{ RAND_12 }" or "{ RAND_24 }" to create a random (not-secure) example seed.""") + +# Optional arguments +parser.add_argument('-z', '--zero-indexed-dice', + action="store_true", + default=False, + dest="zero_indexed_dice", + help="Enables dice entry as [0-5] instead of default [1-6]") + +args = parser.parse_args() + +method = args.method +entropy = args.entropy +zero_indexed_dice = args.zero_indexed_dice + +is_rand_seed = 'rand' in entropy +if is_rand_seed: + # Generate random data as our entropy + if entropy not in [RAND_12, RAND_24]: + print(f"""Invalid random entropy value: Must be either "{RAND_12}" or "{RAND_24}".""") + exit(1) + mnemonic_length = 12 if entropy == RAND_12 else 24 + + if method == 'dice': + if zero_indexed_dice: + entropy = ''.join([str(random.randint(0, 5)) for i in range(mnemonic_generation.DICE__NUM_ROLLS__12WORD if mnemonic_length == 12 else mnemonic_generation.DICE__NUM_ROLLS__24WORD)]) + else: + entropy = ''.join([str(random.randint(1, 6)) for i in range(mnemonic_generation.DICE__NUM_ROLLS__12WORD if mnemonic_length == 12 else mnemonic_generation.DICE__NUM_ROLLS__24WORD)]) + + elif method == 'coins': + entropy = ''.join([str(random.randint(0, 1)) for i in range(128 if mnemonic_length == 12 else 256)]) + + elif method == 'final_word': + random_dice_rolls = ''.join([str(random.randint(0, 1)) for i in range(128 if mnemonic_length == 12 else 256)]) + entropy = " ".join(mnemonic_generation.generate_mnemonic_from_coin_flips(random_dice_rolls)[:-1]) + print(len(entropy.split()), entropy) + +if method == 'dice': + if not zero_indexed_dice and ('0' in entropy or '6' not in entropy): + print("Dice entry must be 1-6 unless --zero-indexed-dice is specified") + exit(1) + if len(entropy) not in [mnemonic_generation.DICE__NUM_ROLLS__12WORD, mnemonic_generation.DICE__NUM_ROLLS__24WORD]: + print(f"Dice entropy must be {mnemonic_generation.DICE__NUM_ROLLS__12WORD} or {mnemonic_generation.DICE__NUM_ROLLS__24WORD} rolls") + exit(1) + mnemonic = mnemonic_generation.generate_mnemonic_from_dice(entropy) + +elif method == 'coins': + if len(entropy) not in [128, 256]: + print("Coin flip entropy must be 128 or 256 flips") + exit(1) + mnemonic = mnemonic_generation.generate_mnemonic_from_coin_flips(entropy) + +elif method == 'final_word': + num_input_words = len(entropy.split()) + if num_input_words not in [11, 12, 23, 24]: + print(f"Final word entropy must be 11, 12, 23, or 24 words ({num_input_words} provided)") + exit(1) + + if num_input_words in [11, 23]: + # We need to fill the last bits of entropy + if num_input_words == 11: + # 7 final bits of entropy in the 12th word (7 + 4-bit checksum) + num_final_entropy_bits = 7 + else: + # 3 final bits of entropy in the 24th word (3 + 8-bit checksum) + num_final_entropy_bits = 3 + + final_entropy_method = None + while final_entropy_method not in ['1', '2', '3']: + final_entropy_method = input(f""" +How would you like to fill the final {num_final_entropy_bits} bits of entropy? + +1.) {num_final_entropy_bits} coin flips +2.) Select an additional word from the wordlist +3.) Fill with zeros + +Type 1, 2, or 3: """) + + if final_entropy_method == '1': + coin_flips = input(f""" Enter {num_final_entropy_bits} coin flips as 0 or 1 (e.g. { "".join(str(random.randint(0,1)) for f in range(0, num_final_entropy_bits)) }): """) + if len(coin_flips) != num_final_entropy_bits: + print(f"Invalid number of coin flips: needed {num_final_entropy_bits}, got {len(coin_flips)}") + final_word = mnemonic_generation.get_partial_final_word(coin_flips) + entropy += f" {final_word}" + + elif final_entropy_method == '2': + final_word = input(f""" Enter the final word: """) + if final_word not in WORDLIST__ENGLISH: + print(f"Invalid word: {final_word}") + exit(1) + entropy += f" {final_word}" + + elif final_entropy_method == '3': + # Nothing to do; just pass the 11 or 23 words straight to calculate_checksum + pass + + mnemonic = mnemonic_generation.calculate_checksum(entropy) + +print("\n") +if is_rand_seed: + print("\t***** This is a demo seed. Do not use it to store funds!!! *****") + +print(f"""\t{" ".join(mnemonic)}\n""") + +if is_rand_seed: + print(f"\tEntropy: {entropy}\n") + +if method == "dice": + print(f"""\tVerify at iancoleman.io/bip39 or bitcoiner.guide/seed using "Base 10" or "Hex" mode.\n""") +elif method == "coins": + print(f"""\tVerify at iancoleman.io/bip39 or bitcoiner.guide/seed using "Binary" mode.\n""") diff --git a/tools/seed_phrase_to_qr.py b/tools/seed_phrase_to_qr.py index 0c818c75..57041e9c 100644 --- a/tools/seed_phrase_to_qr.py +++ b/tools/seed_phrase_to_qr.py @@ -1,19 +1,44 @@ +from seedsigner.models.encode_qr import CompactSeedQrEncoder, SeedQrEncoder +from seedsigner.models.seed import Seed +from seedsigner.models.settings_definition import SettingsConstants + +""" +This is a utility for testing / dev purposes only. +""" if __name__ == "__main__": import qrcode import sys - from embit import bip39 - print(sys.argv) - seed_phrase = sys.argv[1:] - print(seed_phrase) + print(""" +******************************************************************************* - data = "" - for word in seed_phrase: - index = bip39.WORDLIST.index(word) - data += str("%04d" % index) + This is a utility for testing / dev purposes ONLY. + + A SeedQR for a real seed holding actual value should never be created + this way. + +******************************************************************************* +""") + + COMPACT = 1 + STANDARD = 2 + format = int(input("1. Compact SeedQR\n2. Standard SeedQR\nEnter 1 or 2: ").strip()) + if format not in [COMPACT, STANDARD]: + print("Invalid option") + sys.exit(1) + + seed_phrase = input("\nEnter 12- or 24-word test seed phrase: ").strip().split(" ") + + if format == COMPACT: + encoder = CompactSeedQrEncoder(seed_phrase=seed_phrase, wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + else: + encoder = SeedQrEncoder(seed_phrase=seed_phrase, wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=5, border=3) - qr.add_data(data) + qr.add_data(encoder.next_part()) qr.make(fit=True) qr.make_image(fill_color="black", back_color="white").resize((240,240)).convert('RGB').show() + + seed = Seed(seed_phrase) + print(f"\nfingerprint: {seed.get_fingerprint()}\n")