Merge branch 'v0.7.0' into kdmukai/initial_multilanguage

Many missing i18n/l10n.  tests all pass. screenshots work for "en" only.
This commit is contained in:
Jean Do
2023-10-07 11:55:13 -04:00
107 changed files with 5233 additions and 1801 deletions
+1
View File
@@ -6,3 +6,4 @@ src/seedsigner.egg-info/
src/seedsigner/models/settings_definition.json
.idea
.coverage
seedsigner-screenshots
+15 -13
View File
@@ -23,7 +23,7 @@ My self-directed exploration, from mid November through mid December 2022, to ca
# 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.
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).
@@ -32,20 +32,22 @@ You can follow [@SeedSigner](https://twitter.com/SeedSigner) on Twitter for the
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
* On-demand receive address verification
* Address Explorer for single sig and multisig wallets
* User-configurable QR code display density
* Responsive, event-driven user interface
@@ -58,10 +60,10 @@ If you have specific questions about the project, our [Telegram Group](https://t
* 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!
---------------
+193 -26
View File
@@ -1,40 +1,207 @@
## 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.<br><br>
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.<br><br>
This guide describes how this can be achieved.<br><br>
As usual: Don't Trust, Verify!<br>
<br><br>
**Note:**<br>
**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!**<br>
**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.**<br>
**Never input seed phrases that you intend to use to store real funds onto an internet-connected computer!!!**
<br><br><br>
### Verifying with Ian coleman webpage
## 99 Dice Rolls / 24 Seed Words Example
Go to https://iancoleman.io/bip39 and check show entropy detail:
<img src="img/dice_entr.png">
The following 99 dice roll results are used in the verification steps as an example for a 24 words seed:<br>
> 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) :
<img src="img/dice_type.png">
The corresponding 24 seed words are:<br>
> 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)
<br><br><br>
## Creating seed via Dice rolls in SeedSigner (here v0.6.0)
#### Here in real life:
First we create a new seed based on dice rolls in SeedSigner:<br><br>
Power on your SeedSigner, go to the 'Tools' menu and select 'New Seed' (with the dice symbols):<br>
<img src="img/dicedoc/sesi_tools_dice_seed.png" width="600">
We start with first 3 roll, it is important to always read dice roll from left to right to avoid human bias:
<img src="img/dice_pic1.png">
Select '24 words (99 rolls)' and on the next screen enter the dice numbers one after another:<br>
<img src="img/dicedoc/sesi_dice_1.png" width="600">
Then we arrive at the 99th dice:
<img src="img/dice_pic2.png">
Go on until the end (99 dice roll numbers):<br>
<img src="img/dicedoc/sesi_dice_2.png" width="600">
Then same 24 words! :
<img src="img/dice_pic3.png">
After that the 24 seed words are shown (in 6 screens of 4 words each):<br>
<img src="img/dicedoc/sesi_seed_1.png" width="600">
<br>**.....**<br>
<img src="img/dicedoc/sesi_seed_2.png" width="600">
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.
The fingerprint for this seed is:<br>
<img src="img/dicedoc/sesi_finger_print.png" width="600">
<br><br><br>
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
### Verifying with Coldcard
We will create a wallet to have the complete zpub and receive/change addresses to check against the two web pages.<br>
SeedSigner currently supports BlueWallet, Nunchuk, Sparrow and Specter Desktop. Here we will use Sparrow wallet as an example.<br>
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
Keep the SeedSigner open and the newly created seed still loaded as we will need it in the next step.
<br><br><br>
## 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.<br><br>
Open Sparrow Wallet, go to 'File' menu and select 'New Wallet'. Enter a name (e.g. test), and click 'Create Wallet'.<br><br>
Click 'Airgapped Hardware Wallet' (1) and click on the 'Scan' button in the SeedSigner entry (2) which will open the camera scan screen:<br>
<kbd><img src="img/dicedoc/sparrow_wallet_1.png"></kbd>
On SeedSigner go to the seed just created and click 'Export Xpub':<br>
<img src="img/dicedoc/sesi_export_xpub_1.png" width="600">
Follow these menu entries in SeedSigner:<br>
> Export Xpub --> Single Sig --> Native Segwit --> Sparrow<br>
<img src="img/dicedoc/sesi_export_xpub_2.png" width="600">
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).<br>
Scan the QR code SeedSigner is showing in Sparrow Wallet.<br><br>
The wallet has now been created in Sparrow. Click 'Apply' button to finalize. The wallet's settings screen now looks like this:<br>
<kbd><img src="img/dicedoc/sparrow_wallet_2.png"></kbd>
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.
<br><br><br>
## Verifying with Ian Coleman BIP39 website
Go to https://iancoleman.io/bip39 and check 'Show entropy details' (1):<br>
<kbd><img src="img/dicedoc/coleman_entropy.png"></kbd>
<br>
Make sure to check (1) 'Hex' and (2) '24 Words' as 'Mnemonic Length'.<br>
(Do not use 'dice' format because dice 6 will be replaced by 0).<br>
Then enter the 99 dices numbers in (3). The corresponding seed words are shown in (4):<br>
<kbd><img src="img/dicedoc/coleman_verify.png"></kbd>
<br><br>
The 24 seed words are the same in SeedSigner and the Ian Coleman tool.
<br><br>
### Verification of (1) fingerprint, (2) zpub and (3) generated addresses
**Fingerprint:**<br>
Fingerprint is not shown in the Ian Coleman tool (so cannot be verified here)
<br><br>
**Zpub:**<br>
Scroll down to the 'Derivation Path' section, click on the 'BIP84' tab (1) and find the zpub in (2):
<kbd><img src="img/dicedoc/coleman_zpub.png"></kbd><br><br>
Compare to zpub in Sparrow:<br>
<kbd><img src="img/dicedoc/sparrow_zpub.png"></kbd>
<br>
Zpub is the same as shown in SeedSigner and Sparrow wallet.
<br><br>
**Addresses:**<br>
Scroll down to the 'Derived Addresses' section and compare the receive addresses to the ones generated in Sparrow ('Addresses' tab of the wallet):
<kbd><img src="img/dicedoc/coleman_addresses.png"></kbd>
<br>
Check that the receive addresses all match.<br><br>
To verify the change addresses, change 'External / Internal' to 1 (1):
<kbd><img src="img/dicedoc/coleman_change_addresses_1.png"></kbd><br><br>
Compare the change addresses to the ones generated in Sparrow ('Addresses' tab of the wallet):
<kbd><img src="img/dicedoc/coleman_change_addresses_2.png"></kbd>
<br>
Check that the change addresses all match.
<br><br><br>
## Verifying with Seed Tool website
Go to https://bitcoiner.guide/seed/ and click on 'Seed Generation Input' (1):<br>
<kbd><img src="img/dicedoc/seedtool_1.png"></kbd>
Then click on the 'Show the Entropy Section' tab (1):<br>
<kbd><img src="img/dicedoc/seedtool_2.png"></kbd>
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):<br>
<kbd><img src="img/dicedoc/seedtool_3.png"></kbd>
Seed words shown are the same as in SeedSigner and the Ian Coleman web tool seen before.
<br><br>
### Verification of (1) fingerprint, (2) zpub and (3) generated addresses
**Fingerprint:**<br>
Fingerprint can be seen here (1): <br>
<kbd><img src="img/dicedoc/seedtool_fingerprint.png"></kbd><br><br>
**Zpub:**<br>
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):<br>
<kbd><img src="img/dicedoc/seedtool_zpub.png"></kbd><br><br>
Compare to zpub in Sparrow:<br>
<kbd><img src="img/dicedoc/sparrow_zpub.png"></kbd>
<br>
Zpub is the same as shown in SeedSigner, Sparrow and Ian Colemand tool.
<br><br>
**Addresses:**<br>
Scroll down a little bit where the receive addresses are shown and compare to the ones generated in Sparrow ('Addresses' tab of the wallet):<br>
<kbd><img src="img/dicedoc/seedtool_addresses.png"></kbd>
<br>
Check that the receive addresses all match.<br><br>
To verify the change addresses, change the 'Receive/Change' dropdown box to '1 (Change)' (1):
<kbd><img src="img/dicedoc/seedtool_change_addresses_1.png"></kbd><br><br>
Compare the change addresses to the ones generated in Sparrow ('Addresses' tab of the wallet):
<kbd><img src="img/dicedoc/seedtool_change_addresses_2.png"></kbd>
<br>
Check that the change addresses all match.
<br><br><br>
## 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.<br><br>
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'.<br><br>
50 dice roll results as an example for a 12 words seed:<br>
> 65515223131652132161133154444123616466443112153441
The corresponding 12 seed words are:<br>
> hole luggage safe present express tragic orbit shed switch metal identify path
Fingerprint:<br>
> 8d9cced8
Zpub:<br>
> zpub6qf9ziL759pzyhKMWaPfNSiCETkoA6oq3fbCDvXqcURiMtPnkEg3nH93W5mrSkvGPoJC9xTYZheYDsYoiYc5AkSk9iY3DkCJHkFgHMdijW6
Addresses:<br>
Receive:<br>
> bc1q00lln3r4mt4uwvg7mxv96xgpewauwmggkex2ff<br>
> bc1q0jj2cv965f3642mv4lgq5za80jtfpkd0jhjefr<br>
> bc1qpecssejm2678v0rknk9tsxd5fsshezfr0vr5m5<br>
> bc1qcsl37xn5rkfz8qhcfwq5u52acxecyyfz0kv4gh<br>
> bc1q8ehx53re0wck4m9tzek8mlnctp2ztm7jq94zm4<br>
> ...<br>
Change:<br>
> bc1qz0ckhg3m349qpmweyn5v6r6tvx2wfw4nv8h75q<br>
> bc1qme5tu2t424ws3z69u445q0yw88vpc7fwygra4r<br>
> bc1qznuyuc087ky7fhv4nvlmll4p586ks4ggcyt36d<br>
> bc1qjcqxv22j0g00pehruwwh34sw5znu6vp3myaspy<br>
> bc1q6dpfl7czd22wt0max6p09vr6lvvpag7xw9u8lc<br>
> ...
<br><br>
## Conclusion
What did we achieve now?<br>
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).<br><br>
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.<br>
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.
### 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!
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+20 -5
View File
@@ -6,11 +6,15 @@ https://downloads.raspberrypi.org/raspios_lite_armhf/images/raspios_lite_armhf-2
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).
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:
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 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).
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
@@ -53,6 +57,7 @@ Set the following:
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
@@ -150,7 +155,7 @@ Edit your bash profile with the command `nano ~/.profile` and add the following
```bash
export WORKON_HOME=$HOME/.envs
export VIRTUALENVWRAPPER_PYTHON=/usr/bin/python3
source /home/pi/.local/bin/virtualenvwrapper.sh
source $HOME/.local/bin/virtualenvwrapper.sh
```
Then `CTRL-X` and `y` to exit and save changes.
@@ -177,6 +182,7 @@ cd seedsigner
```bash
# Takes 1hr 45min on a Pi Zero 1.3
pip install -r requirements.txt
pip install -r requirements-raspi.txt
```
#### `pyzbar`
@@ -209,13 +215,22 @@ workon seedsigner-env
pwd
```
### 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
+3 -6
View File
@@ -298,16 +298,13 @@ 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:
<img src="img/zxing_screenshot.png">
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.
+34
View File
@@ -0,0 +1,34 @@
## The Rugged Pill Enclosure
<img src="/docs/img/Rugged_Pill_Thumb.jpg" width="600" height="400">
- 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)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
picamera==1.13
RPi.GPIO==0.7.0
spidev==3.5
-2
View File
@@ -6,7 +6,5 @@ Pillow==9.1.0
pytz==2022.1
-e git+https://github.com/seedsigner/pyzbar.git@c3c237821c6a20b17953efe59b90df0b514a1c03#egg=pyzbar
qrcode==7.3.1
RPi.GPIO==0.7.0
six==1.16.0
spidev==3.5
urtypes @ git+https://github.com/selfcustody/urtypes.git@7fb280eab3b3563dfc57d2733b0bf5cbc0a96a6a
+1 -1
View File
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
setuptools.setup(
name="seedsigner",
version="0.6.0",
version="0.7.0",
author="SeedSigner",
author_email="author@example.com",
description="Build an offline, airgapped Bitcoin signing device for less than $50!",
+165 -41
View File
@@ -1,27 +1,23 @@
import time
import logging
import traceback
import gettext
import os
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.hardware.microsd import MicroSD
from seedsigner.views.screensaver import ScreensaverScreen
from seedsigner.views.view import Destination, NotYetImplementedView, UnhandledExceptionView
from .models import Seed, SeedStorage, Settings, Singleton, PSBTParser
from seedsigner.models.settings import Settings
from seedsigner.models.singleton import Singleton
from seedsigner.models.threads import BaseThread
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 "[]"
@@ -33,6 +29,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.
@@ -51,29 +95,29 @@ class Controller(Singleton):
rather than at the top in order avoid circular imports.
"""
VERSION = "0.6.0"
VERSION = "0.7.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?
psbt: PSBT = None
psbt_seed: Seed = None
psbt_parser: PSBTParser = None
psbt: 'embit.psbt.PSBT' = None
psbt_seed: 'Seed' = None
psbt_parser: 'PSBTParser' = None
unverified_address = None
multisig_wallet_descriptor: Descriptor = None
multisig_wallet_descriptor: 'embit.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
@@ -83,10 +127,12 @@ class Controller(Singleton):
FLOW__VERIFY_MULTISIG_ADDR = "multisig_addr"
FLOW__VERIFY_SINGLESIG_ADDR = "singlesig_addr"
FLOW__ADDRESS_EXPLORER = "address_explorer"
FLOW__SIGN_MESSAGE = "sign_message"
resume_main_flow: str = None
back_stack: BackStack = None
screensaver: ScreensaverScreen = None
screensaver: 'ScreensaverScreen' = None
toast_notification_thread: 'BaseToastOverlayManagerThread' = None
# Babel initialization
gettext.install('messages', localedir='seedsigner/resources/babel')
@@ -114,6 +160,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")
@@ -122,15 +171,7 @@ 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()
@@ -143,13 +184,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
@@ -157,9 +199,18 @@ 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:
def get_seed(self, seed_num: int) -> 'Seed':
if seed_num < len(self.storage.seeds):
return self.storage.seeds[seed_num]
else:
@@ -174,7 +225,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()
@@ -189,12 +239,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!
@@ -222,7 +278,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:
@@ -244,14 +307,29 @@ class Controller(Singleton):
print(f"back_stack: {self.back_stack}")
try:
# Instantiate the View class and run it
print(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:
@@ -284,16 +362,61 @@ class Controller(Singleton):
print("-" * 30)
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")
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():
print(f"Controller: settings toggle_render_lock for {self.toast_notification_thread.__class__.__name__}")
self.toast_notification_thread.toggle_renderer_lock()
print("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()
print("Controller: Screensaver started")
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
print("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
print(f"Controller: stopping {self.toast_notification_thread.__class__.__name__}")
self.toast_notification_thread.stop()
self.toast_notification_thread = toast_manager_thread
print(f"Controller: starting {self.toast_notification_thread.__class__.__name__}")
self.toast_notification_thread.start()
def handle_exception(self, e) -> Destination:
@@ -306,6 +429,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:
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -214,8 +214,7 @@ class Keyboard:
# Set up the rendering and state params
self.active_keys = list(self.charset)
# TODO: Remove RobotoCondensed font dependency
self.additonal_key_compact_font = Fonts.get_font("RobotoCondensed-Bold.ttf", 18)
self.additonal_key_compact_font = Fonts.get_font("RobotoCondensed-Bold", 18)
self.x_start = rect[0]
self.y_start = rect[1]
self.x_gap = 2
+8 -123
View File
@@ -4,7 +4,8 @@ from PIL import Image, ImageDraw
from threading import Lock
from seedsigner.gui.components import Fonts, GUIConstants
from seedsigner.models import ConfigurableSingleton
from seedsigner.hardware.ST7789 import ST7789
from seedsigner.models.singleton import ConfigurableSingleton
@@ -20,8 +21,6 @@ class Renderer(ConfigurableSingleton):
@classmethod
def configure_instance(cls):
from seedsigner.hardware.ST7789 import ST7789
# Instantiate the one and only Renderer instance
renderer = cls.__new__(cls)
cls._instance = renderer
@@ -35,10 +34,12 @@ class Renderer(ConfigurableSingleton):
renderer.draw = ImageDraw.Draw(renderer.canvas)
def show_image(self, image=None, alpha_overlay=None, is_background_thread: bool = False):
"""
is_background_thread: Only used by the ScreenshotRenderer.
"""
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
@@ -85,123 +86,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.get_body_font_name(), GUIConstants.get_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()
+7 -7
View File
@@ -9,7 +9,7 @@ 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)
@@ -661,10 +661,10 @@ class PSBTChangeDetailsScreen(ButtonListScreen):
# Adjust the vertical spacing
screen_y -= GUIConstants.COMPONENT_PADDING
self.components.append(IconTextLine(
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
icon_color="blue",
# TRANSLATOR_NOTE: First variable is either "Multisig" or the fingerprint for single sig; Second is "Change" or "Addr" (change vs receive addr); Third is the address index number (e.g. your #4 receive addr).
value_text="""{}: {} #{}""".format(_("Multisig") if self.is_multisig else self.fingerprint, _("Change") if self.is_change_derivation_path else _("Addr"), self.derivation_path_addr_index),
icon_name=SeedSignerIconConstants.FINGERPRINT,
icon_color=GUIConstants.INFO_COLOR,
is_text_centered=False,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=screen_y,
@@ -672,8 +672,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,
@@ -691,8 +691,8 @@ class PSBTFinalizeScreen(ButtonListScreen):
super().__post_init__()
icon = Icon(
icon_name=FontAwesomeIconConstants.FILE_SIGNATURE,
icon_color=GUIConstants.LIGHT_BLUE,
icon_name=FontAwesomeIconConstants.PAPER_PLANE,
icon_color=GUIConstants.INFO_COLOR,
icon_size=GUIConstants.ICON_LARGE_BUTTON_SIZE,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING
)
+83 -68
View File
@@ -2,27 +2,51 @@ import time
from dataclasses import dataclass
from gettext import gettext as _
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.decode_qr import DecodeQR, DecodeQRStatus
from seedsigner.models.threads import BaseThread
from .screen import BaseScreen, BaseTopNavScreen, ButtonListScreen
from ..components import BaseComponent, Button, GUIConstants, Fonts, IconButton, TextArea, calc_text_centering
from .screen import BaseScreen, ButtonListScreen
from ..components import GUIConstants, Fonts, TextArea
@dataclass
class ScanScreen(BaseScreen):
"""
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 = _("< back | Scan a QR code")
resolution: Tuple[int,int] = (480, 480)
framerate: int = 12
render_rect: Tuple[int,int,int,int] = 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
def __post_init__(self):
@@ -30,6 +54,8 @@ class ScanScreen(BaseScreen):
# 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")
@@ -43,7 +69,7 @@ class ScanScreen(BaseScreen):
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]):
self.camera = camera
self.decoder = decoder
self.renderer = renderer
@@ -54,9 +80,7 @@ class ScanScreen(BaseScreen):
self.render_rect = (0, 0, self.renderer.canvas_width, self.renderer.canvas_height)
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__()
@@ -65,44 +89,62 @@ class ScanScreen(BaseScreen):
from timeit import default_timer as timer
instructions_font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_button_font_size())
start_time = time.time()
num_frames = 0
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
num_frames += 1
cur_time = time.time()
cur_fps = num_frames / (cur_time - start_time)
if self.decoder and self.decoder.get_percent_complete() > 0 and self.decoder.is_psbt:
scan_text = str(self.decoder.get_percent_complete()) + "% Complete"
if show_framerate:
scan_text += f" {cur_fps:0.2f} | {self.decoder_fps}"
else:
if show_framerate:
scan_text = f"{cur_fps:0.2f} | {self.decoder_fps}"
else:
scan_text = self.instructions_text
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])
)
draw = ImageDraw.Draw(frame)
if scan_text:
self.renderer.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,
stroke_width=4,
stroke_fill=GUIConstants.BACKGROUND_COLOR,
anchor="ms"
)
# 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.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")
self.renderer.show_image()
# 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")
end = timer()
# print(f"{1.0/(end - start)} fps") # Time in seconds, e.g. 5.38091952400282
self.renderer.show_image(frame, show_direct=True)
# print(f" {cur_fps:0.2f} | {self.decoder_fps}")
time.sleep(0.05) # turn this up or down to tune performance while decoding psbt
if self.camera._video_stream is None:
break
@@ -113,49 +155,22 @@ 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
# 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
def __post_init__(self):
# Customize defaults
self.title = _("Settings QR")
self.is_bottom_list = True
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
))
+122 -50
View File
@@ -4,16 +4,15 @@ from dataclasses import dataclass
from gettext import gettext as _
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.hardware.buttons import HardwareButtons, HardwareButtonsConstants
from seedsigner.models.threads import BaseThread, ThreadsafeCounter
from seedsigner.models.encode_qr import EncodeQR
from seedsigner.hardware.buttons import HardwareButtonsConstants, HardwareButtons
from seedsigner.models.settings import SettingsConstants
from ..components import (FontAwesomeIconConstants, GUIConstants, BaseComponent, Button, Icon, IconButton, LargeIconButton, SeedSignerCustomIconConstants, TopNav, TextArea, load_image, ToastOverlay)
from seedsigner.models.threads import BaseThread, ThreadsafeCounter
# Must be huge numbers to avoid conflicting with the selected_button returned by the
@@ -678,28 +677,112 @@ class LargeButtonScreen(BaseTopNavScreen):
@dataclass
class QRDisplayScreen(BaseScreen):
qr_encoder: EncodeQR = None
qr_encoder: 'EncodeQR' = None
class QRDisplayThread(BaseThread):
def __init__(self, qr_encoder: EncodeQR, qr_brightness: ThreadsafeCounter, renderer: Renderer):
def __init__(self, qr_encoder: 'EncodeQR', 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 add_brightness_tips(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.get_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.get_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.get_body_font_size(),
font_name=GUIConstants.get_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.get_body_font_size(),
font_name=GUIConstants.get_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)
show_brightness_tips = cur_brightness_setting == SettingsConstants.OPTION__ENABLED
# Loop whether the QR is a single frame or animated; each loop might adjust
# brightness setting.
while self.keep_running:
# convert the self.qr_brightness integer (31-255) into hex triplets
hex_color = (hex(self.qr_brightness.cur_count).split('x')[1]) * 3
image = self.qr_encoder.next_part_image(240,240, border=2, background_color=hex_color)
image = self.qr_encoder.next_part_image(240, 240, border=2, background_color=hex_color)
# Display the brightness tips toast
duration = 10 ** 9 * 1.2 # 1.2 seconds
if show_brightness_tips and time.time_ns() - self.tips_start_time.cur_count < duration:
self.add_brightness_tips(image)
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)
time.sleep(5 / 30.0)
def __post_init__(self):
@@ -708,12 +791,15 @@ class QRDisplayScreen(BaseScreen):
# 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.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
))
@@ -734,10 +820,12 @@ class QRDisplayScreen(BaseScreen):
if user_input == HardwareButtonsConstants.KEY_DOWN:
# Reduce QR code background brightness
self.qr_brightness.set_value(max(31, self.qr_brightness.cur_count - 31))
self.tips_start_time.set_value(time.time_ns())
elif user_input == HardwareButtonsConstants.KEY_UP:
# Incrase QR code background brightness
self.qr_brightness.set_value(min(self.qr_brightness.cur_count + 31, 255))
self.tips_start_time.set_value(time.time_ns())
else:
# Any other input exits the screen
@@ -753,12 +841,14 @@ class QRDisplayScreen(BaseScreen):
@dataclass
class LargeIconStatusScreen(ButtonListScreen):
title: str = None
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 = None
text: str = "" # The body text of the screen
text_edge_padding: int = GUIConstants.EDGE_PADDING
button_data: list = None
allow_text_overflow: bool = False
def __post_init__(self):
@@ -779,7 +869,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,
@@ -794,6 +884,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,
))
@@ -866,6 +957,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__()
@@ -876,12 +968,17 @@ class WarningEdgesMixin:
@dataclass
class WarningScreen(WarningEdgesMixin, LargeIconStatusScreen):
status_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION
title: str = None
status_icon_name: str = SeedSignerIconConstants.WARNING
status_color: str = "yellow"
status_headline: str = "Privacy Leak!" # The colored text under the alert icon
def __post_init__(self):
if not self.title:
self.title = _("Caution")
if not self.status_headline:
self.status_headline = _("Privacy Leak!")
if not self.button_data:
self.button_data = [_("I Understand")]
@@ -892,6 +989,7 @@ class WarningScreen(WarningEdgesMixin, LargeIconStatusScreen):
@dataclass
class DireWarningScreen(WarningScreen):
status_headline: str = "Classified Info!" # The colored text under the alert icon
status_color: str = GUIConstants.DIRE_WARNING_COLOR
def __post_init__(self):
@@ -997,7 +1095,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,
@@ -1160,37 +1258,11 @@ class KeyboardScreen(BaseTopNavScreen):
"""
return False
class MicroSDToastScreen(BaseScreen):
"""
This screen is an overlay with special behavior with the ToastOverlay component. The ToastOverlay component overides all button
input and captures the existing screen content and stashes it to be restored once X second passes or any button is pressed. The
display method on this screen will not complete until after the ToastOverlay render method is complete it's takeover of the screen.
"""
def __init__(self, action):
self.action = action
self.toast = None
super().__init__()
def _run(self):
return
def _render(self):
from seedsigner.hardware.microsd import MicroSD
if self.action == MicroSD.ACTION__REMOVED:
self.toast = ToastOverlay(
icon_name=FontAwesomeIconConstants.SDCARD,
color=GUIConstants.NOTIFICATION_COLOR,
label_text=_("MicroSD removed")
)
elif self.action == MicroSD.ACTION__INSERTED:
self.toast = ToastOverlay(
icon_name=FontAwesomeIconConstants.SDCARD,
color=GUIConstants.NOTIFICATION_COLOR,
label_text=_("MicroSD inserted")
)
self.toast.render()
@dataclass
class MainMenuScreen(LargeButtonScreen):
# Override LargeButtonScreen defaults
title_font_size: int = 26
show_back_button: bool = False
show_power_button: bool = True
+107 -57
View File
@@ -10,11 +10,9 @@ from seedsigner.gui.renderer import Renderer
from seedsigner.helpers.qr import QR
from seedsigner.models.threads import BaseThread, ThreadsafeCounter
from seedsigner.models.seed import Seed
from .screen import RET_CODE__BACK_BUTTON, BaseScreen, BaseTopNavScreen, ButtonListScreen, KeyboardScreen, WarningEdgesMixin
from ..components import (Button, FontAwesomeIconConstants, Fonts, FormattedAddress, Icon, IconButton,
IconTextLine, SeedSignerCustomIconConstants, TextArea, GUIConstants)
from ..components import (Button, FontAwesomeIconConstants, Fonts, FormattedAddress, IconButton,
IconTextLine, SeedSignerIconConstants, TextArea, GUIConstants, reflow_text_into_pages)
from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay
from seedsigner.hardware.buttons import HardwareButtons, HardwareButtonsConstants
@@ -418,8 +416,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,
@@ -438,8 +436,8 @@ class SeedOptionsScreen(ButtonListScreen):
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
@@ -519,7 +517,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)
@@ -527,7 +525,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
)
@@ -549,6 +547,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):
@@ -600,8 +614,9 @@ 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,
# TRANSLATOR_NOTE: Short for "BIP32 Master Fingerprint"
label_text=_("Fingerprint"),
value_text=self.fingerprint,
screen_x=GUIConstants.COMPONENT_PADDING,
@@ -610,7 +625,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,
# TRANSLATOR_NOTE: Short for "Derivation Path"
label_text=_("Derivation"),
value_text=self.derivation_path,
@@ -621,6 +637,8 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen):
self.xpub_line = IconTextLine(
icon_name=FontAwesomeIconConstants.X,
icon_color=GUIConstants.INFO_COLOR,
# TRANSLATOR_NOTE: Short for "BIP32 Extended Public Key"
label_text=_("Xpub"),
value_text=f"{self.xpub[:18]}...",
font_name=GUIConstants.FIXED_WIDTH_FONT_NAME,
@@ -809,7 +827,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,
@@ -1024,41 +1042,14 @@ class SeedReviewPassphraseScreen(ButtonListScreen):
super().__post_init__()
fingerprint_change_label = TextArea(
self.components.append(IconTextLine(
icon_name=SeedSignerIconConstants.FINGERPRINT,
icon_color=GUIConstants.INFO_COLOR,
# TRANSLATOR_NOTE: Describes the effect of applying a BIP-39 passphrase; it changes the seed's fingerprint
text=_("changes seed:"),
font_size=GUIConstants.LABEL_FONT_SIZE,
font_color=GUIConstants.LABEL_FONT_COLOR,
is_text_centered=False,
screen_x=int(1.25*GUIConstants.EDGE_PADDING),
screen_y=self.buttons[0].screen_y - GUIConstants.COMPONENT_PADDING - int(GUIConstants.get_body_font_size()*2.25)
)
self.components.append(fingerprint_change_label)
screen_y = fingerprint_change_label.screen_y + fingerprint_change_label.height + int(GUIConstants.COMPONENT_PADDING/2)
self.components.append(TextArea(
text=self.fingerprint_without,
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
font_size=GUIConstants.get_body_font_size() + 4,
font_color=GUIConstants.LABEL_FONT_COLOR,
is_text_centered=False,
screen_x=int(1.25*GUIConstants.EDGE_PADDING),
screen_y=screen_y,
))
self.components.append(Icon(
icon_name=FontAwesomeIconConstants.CARET_RIGHT,
icon_size=GUIConstants.get_body_font_size() + 4,
icon_color=GUIConstants.LIGHT_BLUE,
screen_x=self.components[-1].screen_x + self.components[-1].width + 2*GUIConstants.COMPONENT_PADDING,
screen_y=screen_y,
))
self.components.append(TextArea(
text=self.fingerprint_with,
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
font_size=GUIConstants.get_body_font_size() + 4,
is_text_centered=False,
screen_x=self.components[-1].screen_x + self.components[-1].width,
screen_y=screen_y,
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.get_body_font_size()*2.5)
))
if self.passphrase != self.passphrase.strip() or " " in self.passphrase:
@@ -1380,7 +1371,7 @@ class AddressVerificationSigTypeScreen(ButtonListScreen):
@dataclass
class SeedSingleSigAddressVerificationSelectSeedScreen(ButtonListScreen):
class SeedSelectSeedScreen(ButtonListScreen):
text: str = ""
def __post_init__(self):
@@ -1536,16 +1527,75 @@ class MultisigWalletDescriptorScreen(ButtonListScreen):
auto_line_break=True,
))
@dataclass
class SeedBIP85SelectChildIndexScreen(KeyboardScreen):
class SeedSignMessageConfirmMessageScreen(ButtonListScreen):
page_num: int = None
def __post_init__(self):
self.title = _("BIP-85 Index")
self.user_input = ""
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
# Specify the keys in the keyboard
self.rows = 3
self.cols = 5
self.keys_charset = "0123456789"
self.show_save_button = True
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,
)
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)
+34 -1
View File
@@ -297,9 +297,42 @@ class DonateScreen(BaseTopNavScreen):
self.components.append(TextArea(
text="seedsigner.com",
font_name=GUIConstants.BODY_FONT_NAME["default"],
font_name=GUIConstants.get_body_font_name(),
font_size=28,
font_color=GUIConstants.ACCENT_COLOR,
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
))
+57 -45
View File
@@ -1,10 +1,11 @@
import time
from dataclasses import dataclass
from gettext import gettext as _
from typing import Any
from PIL.Image import Image
from seedsigner.hardware.camera import Camera
from seedsigner.gui.components import FontAwesomeIconConstants, Fonts, GUIConstants, IconTextLine, SeedSignerCustomIconConstants, TextArea
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
@@ -18,7 +19,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):
@@ -28,28 +29,6 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
instructions_font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_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
),
# TRANSLATOR_NOTE: Instructions when taking a picture; "back" should point left.
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)
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 +36,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 +67,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
@@ -167,7 +177,7 @@ class ToolsCalcFinalWordFinalizePromptScreen(ButtonListScreen):
self.components.append(TextArea(
# TRANSLATOR_NOTE: Number of BIP-39 seed words, and the entropy -- in bits, contained within.
text=_("The {}th word is built from {} more entropy bits plus auto-calculated checksum.").format(self.mnemonic_length, self.num_entropy_bits),
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
screen_y=self.top_nav.height + int(GUIConstants.COMPONENT_PADDING/2),
))
@@ -246,24 +256,27 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
self.components.append(TextArea(
# TRANSLATOR_NOTE: A labeled presentation of a user-supplied value
text=_('Your input: "{}"').format(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,
@@ -272,7 +285,6 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
edge_padding=0,
screen_x=checksum_x,
screen_y=screen_y,
height=bit_font_height,
is_text_centered=False,
))
@@ -281,7 +293,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
# TRANSLATOR_NOTE: A function of "x" to be used for detecting errors in "x"
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
@@ -297,8 +309,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,
))
@@ -319,6 +330,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
# TRANSLATOR_NOTE: labeled presentation of the last word in a BIP-39 mnemonic seed phrase.
text=_('Final Word: "{}"').format(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...
@@ -375,8 +387,8 @@ class ToolsCalcFinalWordDoneScreen(ButtonListScreen):
))
self.components.append(IconTextLine(
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
icon_color="blue",
icon_name=SeedSignerIconConstants.FINGERPRINT,
icon_color=GUIConstants.INFO_COLOR,
# TRANSLATOR_NOTE: a label for the shortened Key-id of a BIP-32 master HD wallet
label_text=_("fingerprint"),
value_text=self.fingerprint,
@@ -401,8 +413,8 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen):
if self.fingerprint:
self.components.append(IconTextLine(
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
icon_color="blue",
icon_name=SeedSignerIconConstants.FINGERPRINT,
icon_color=GUIConstants.INFO_COLOR,
# TRANSLATOR_NOTE: a label for the shortened Key-id of a BIP-32 master HD wallet
label_text=_("Fingerprint"),
value_text=self.fingerprint,
@@ -412,21 +424,21 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen):
if self.script_type != SettingsConstants.CUSTOM_DERIVATION:
self.components.append(IconTextLine(
icon_name=SeedSignerCustomIconConstants.PATH,
icon_name=SeedSignerIconConstants.DERIVATION,
# TRANSLATOR_NOTE: a label for the derivation-path into a BIP-32 HD wallet
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 + GUIConstants.COMPONENT_PADDING,
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
))
else:
self.components.append(IconTextLine(
icon_name=SeedSignerCustomIconConstants.PATH,
icon_name=SeedSignerIconConstants.DERIVATION,
# l10n_note already exists.
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 + GUIConstants.COMPONENT_PADDING,
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
))
else:
@@ -434,7 +446,7 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen):
# TRANSLATOR_NOTE: a label for a BIP-380-ish Output Descriptor
label_text=_("Wallet descriptor"),
value_text=self.wallet_descriptor_display_name,
is_text_centered=False,
is_text_centered=True,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
))
+231
View File
@@ -0,0 +1,231 @@
import time
from dataclasses import dataclass
from seedsigner.gui.components import BaseComponent, GUIConstants, Icon, SeedSignerIconConstants, TextArea
from seedsigner.models.threads import BaseThread
@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):
print(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
print(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.
print(f"{self.__class__.__name__}: Acquiring lock")
self.renderer.lock.acquire()
print(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
print(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
print(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
print(f"{self.__class__.__name__}: Blocking to re-acquire lock")
self.renderer.lock.acquire()
print(f"{self.__class__.__name__}: Lock re-acquired")
if not has_rendered:
previous_screen_state = self.renderer.canvas.copy()
print(f"{self.__class__.__name__}: Showing toast")
self.toast.render()
has_rendered = True
if time.time() - start > self.activation_delay + self.duration and has_rendered:
print(f"{self.__class__.__name__}: Hiding toast")
break
# Free up cpu resources for main thread
time.sleep(0.1)
finally:
print(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)
print(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:
body_font_size = GUIConstants.get_body_font_size()
return ToastOverlay(
icon_name=SeedSignerIconConstants.MICROSD,
label_text="You can remove\nthe SD card now",
font_size=body_font_size,
height=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:
print("instantiating toast!")
return ToastOverlay(
icon_name=SeedSignerIconConstants.MICROSD,
label_text=self.message,
)
+2 -4
View File
@@ -159,13 +159,11 @@ class ST7789(object):
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)
+1 -1
View File
@@ -81,7 +81,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()
+3 -4
View File
@@ -1,9 +1,9 @@
import io
import numpy
from PIL import Image
from seedsigner.models import Singleton
from seedsigner.models.settings import SettingsConstants
from seedsigner.hardware.pivideostream import PiVideoStream
from seedsigner.models.settings import Settings, SettingsConstants
from seedsigner.models.singleton import Singleton
@@ -15,7 +15,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))
+63 -47
View File
@@ -1,55 +1,71 @@
import os
import time
from seedsigner.models.singleton import Singleton
from seedsigner.models.threads import BaseThread
from seedsigner.models.settings import Settings
#from seedsigner.views.view import MicroSDToastView
from seedsigner.gui.screens.screen import MicroSDToastScreen
class MicroSD(Singleton, BaseThread):
ACTION__INSERTED = "add"
ACTION__REMOVED = "remove"
MOUNT_POINT = "/mnt/microsd"
FIFO_PATH = "/tmp/mdev_fifo"
FIFO_MODE = 0o600
ACTION__INSERTED = "add"
ACTION__REMOVED = "remove"
settings_handler = None
@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
def start_detection(self):
self.start()
def run(self):
import os
FIFO_PATH = "/tmp/mdev_fifo"
FIFO_MODE = 0o600
action = ""
# explicitly only microsd add/remove detection in seedsigner-os
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
if os.path.exists(FIFO_PATH):
os.remove(FIFO_PATH)
os.mkfifo(FIFO_PATH, FIFO_MODE)
while self.keep_running:
with open(FIFO_PATH) as fifo:
action = fifo.read()
print(f"fifo message: {action}")
Settings.microsd_handler(action=action)
toastscreen = MicroSDToastScreen(action=action)
toastscreen.display()
@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()
print(f"fifo message: {action}")
Settings.handle_microsd_state_change(action=action)
Controller.get_instance().activate_toast(SDCardStateChangeToastManagerThread(action=action))
time.sleep(0.1)
+105 -2
View File
@@ -1,9 +1,14 @@
import embit
from embit import bip32
from binascii import b2a_base64
from hashlib import sha256
from embit import bip32, compact, ec
from embit.bip32 import HDKey
from embit.descriptor import Descriptor
from embit.networks import NETWORKS
from embit.networks import NETWORKS
from embit.util import secp256k1
from seedsigner.models.settings_definition import SettingsConstants
@@ -93,3 +98,101 @@ def get_multisig_address(descriptor: Descriptor, index: int = 0, is_change: bool
elif descriptor.is_taproot:
# TODO: Not yet implemented!
raise Exception("Taproot verification not yet implemented!")
def 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": {
"84h": SettingsConstants.NATIVE_SEGWIT,
"49h": SettingsConstants.NESTED_SEGWIT,
"86h": SettingsConstants.TAPROOT,
},
"networks": {
"0h": SettingsConstants.MAINNET,
"1h": [SettingsConstants.TESTNET, SettingsConstants.REGTEST],
}
}
details = dict()
details["script_type"] = lookups["script_types"].get(sections[1])
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()
@@ -5,7 +5,6 @@ from embit import bip39
from embit.bip39 import mnemonic_to_bytes, mnemonic_from_bytes
from typing import List
from seedsigner.models.seed import Seed
def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str]:
@@ -15,6 +14,7 @@ def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str]
If 11- or 23-words are provided, append word `0000` to end of list as temp final
word.
"""
from seedsigner.models.seed import Seed
if len(mnemonic) in [11, 23]:
mnemonic.append(Seed.get_wordlist(wordlist_language_code)[0])
+2 -2
View File
@@ -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
@@ -255,7 +255,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)))
@@ -84,7 +84,7 @@ 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)
@@ -93,9 +93,9 @@ class FountainEncoder:
@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 +104,7 @@ class FountainEncoder:
if fragment_len <= max_fragment_len:
break
assert(fragment_len != None)
assert fragment_len != None
return fragment_len
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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]
-10
View File
@@ -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 *
+96 -104
View File
@@ -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
@@ -132,6 +134,9 @@ class DecodeQR:
return rt
# TODO: Refactor all of these specific `get_` to just something generic like
# `get_data` and let each QRDecoder class return whatever it needs to as a
# str, tuple, dict, etc?
def get_psbt(self):
if self.complete:
data = self.get_data_psbt()
@@ -175,12 +180,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 +193,15 @@ class DecodeQR:
return self.decoder.get_address_type()
def get_qr_data(self) -> dict:
"""
This provides a single access point for external code to retrieve the QR data,
regardless of which decoder is actually instantiated.
"""
# TODO: Implement this approach across all decoders
return self.decoder.get_qr_data()
def get_wallet_descriptor(self):
if self.is_wallet_descriptor:
if self.qr_type in [QRType.OUTPUT__UR, QRType.ACCOUNT__UR, QRType.BYTES__UR]:
@@ -273,6 +282,11 @@ class DecodeQR:
return self.qr_type == QRType.BITCOIN_ADDRESS
@property
def is_sign_message(self):
return self.qr_type == QRType.SIGN_MESSAGE
@property
def is_wallet_descriptor(self):
check = self.qr_type in [QRType.WALLET__SPECTER, QRType.WALLET__UR, QRType.WALLET__CONFIGFILE, QRType.WALLET__GENERIC, QRType.OUTPUT__UR]
@@ -363,8 +377,12 @@ class DecodeQR:
elif DecodeQR.is_bitcoin_address(s):
return QRType.BITCOIN_ADDRESS
# message signing
elif DecodeQR.is_sign_message(s):
return QRType.SIGN_MESSAGE
# config data
if "type=settings" in s:
if s.startswith("settings::"):
return QRType.SETTINGS
# Seed
@@ -482,7 +500,13 @@ class DecodeQR:
return True
else:
return False
@staticmethod
def is_sign_message(s):
return type(s) == str and s.startswith("signmessage")
@staticmethod
def multisig_setup_file_to_descriptor(text) -> str:
# sample text file, parse the contents and create descriptor
@@ -578,6 +602,8 @@ class DecodeQR:
return descriptor
class BaseQrDecoder:
def __init__(self):
self.total_segments = None
@@ -590,6 +616,10 @@ class BaseQrDecoder:
def add(self, segment, qr_type):
raise Exception("Not implemented in child class")
def get_qr_data(self) -> dict:
# TODO: standardize this approach across all decoders (example: SignMessageQrDecoder)
raise Exception("get_qr_data must be implemented in decoder child class")
@@ -673,6 +703,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 +858,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":
print(f"Sign message: Unsupported format: {fmt}")
return DecodeQRStatus.INVALID
self.complete = True
self.collected_segments = 1
return DecodeQRStatus.COMPLETE
def get_qr_data(self) -> dict:
return dict(derivation_path=self.derivation_path, message=self.message)
class BitcoinAddressQrDecoder(BaseSingleFrameQrDecoder):
@@ -1091,11 +1084,10 @@ class GenericWalletQrDecoder(BaseSingleFrameQrDecoder):
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)
+34 -20
View File
@@ -11,12 +11,13 @@ 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.qr_type import 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
@@ -38,6 +39,7 @@ class EncodeQR:
qr_density: str = SettingsConstants.DENSITY__MEDIUM
wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH
bitcoin_address: str = None
signed_message: str = None
def __post_init__(self):
self.qr = QR()
@@ -97,9 +99,13 @@ class EncodeQR:
self.encoder = CompactSeedQrEncoder(seed_phrase=self.seed_phrase,
wordlist_language_code=self.wordlist_language_code)
# Misc formats
elif self.qr_type == QRType.BITCOIN_ADDRESS:
self.encoder = BitcoinAddressEncoder(address=self.bitcoin_address)
elif self.qr_type == QRType.SIGN_MESSAGE:
self.encoder = SignedMessageEncoder(signed_message=self.signed_message)
else:
raise Exception('QR Type not supported')
@@ -154,6 +160,17 @@ class BaseQrEncoder:
class BaseStaticQrEncoder(BaseQrEncoder):
def seq_len(self):
return 1
@property
def is_complete(self):
return True
class BasePsbtQrEncoder(BaseQrEncoder):
def __init__(self, psbt: PSBT):
self.psbt = psbt
@@ -263,7 +280,7 @@ class SpecterPsbtQrEncoder(BasePsbtQrEncoder):
class SeedQrEncoder(BaseQrEncoder):
class SeedQrEncoder(BaseStaticQrEncoder):
def __init__(self, seed_phrase: List[str], wordlist_language_code: str):
super().__init__()
self.seed_phrase = seed_phrase
@@ -273,10 +290,6 @@ class SeedQrEncoder(BaseQrEncoder):
raise Exception('Wordlist Required')
def seq_len(self):
return 1
def next_part(self):
data = ""
# Output as Numeric data format
@@ -286,11 +299,6 @@ class SeedQrEncoder(BaseQrEncoder):
return data
@property
def is_complete(self):
return True
class CompactSeedQrEncoder(SeedQrEncoder):
def next_part(self):
@@ -322,23 +330,29 @@ class CompactSeedQrEncoder(SeedQrEncoder):
class BitcoinAddressEncoder(BaseQrEncoder):
class BitcoinAddressEncoder(BaseStaticQrEncoder):
def __init__(self, address: str):
super().__init__()
self.address = address
def seq_len(self):
return 1
def next_part(self):
return self.address
@property
def is_complete(self):
return True
class SignedMessageEncoder(BaseStaticQrEncoder):
"""
Assumes that a signed message will fit in a single-frame QR
"""
def __init__(self, signed_message: str):
super().__init__()
self.signed_message = signed_message
def next_part(self):
return self.signed_message
class XpubQrEncoder(BaseQrEncoder):
+2 -2
View File
@@ -1,12 +1,12 @@
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
+2
View File
@@ -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"
+2 -1
View File
@@ -6,7 +6,6 @@ from embit.networks import NETWORKS
from typing import List
from seedsigner.models.settings import SettingsConstants
from seedsigner.helpers import embit_utils
class InvalidSeedException(Exception):
@@ -113,6 +112,8 @@ class Seed:
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))
+6 -2
View File
@@ -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, InvalidSeedException
from seedsigner.models.settings_definition import SettingsConstants
@@ -64,6 +63,11 @@ class SeedStorage:
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
+101 -36
View File
@@ -4,12 +4,15 @@ import os
import pathlib
import platform
from typing import Any, List
from typing import List
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
from .singleton import Singleton
from seedsigner.models.singleton import Singleton
class InvalidSettingsQRData(Exception):
pass
class Settings(Singleton):
@@ -30,7 +33,7 @@ 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))
# Setup Babel
path = os.path.join(
@@ -47,6 +50,70 @@ class Settings(Singleton):
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:
print(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)
@@ -61,32 +128,22 @@ class Settings(Singleton):
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
@@ -94,9 +151,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...
for key, value in new_settings.items():
self.set_value(key, value)
@@ -204,28 +258,39 @@ class Settings(Singleton):
return self._data[SettingsConstants.SETTING__DEBUG] == SettingsConstants.OPTION__ENABLED
def microsd_handler(action):
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:
# restore persistent settings back to defaults
# 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 = _("Store Settings on SD card.")
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:
# set persistent settings to disabled value directly
# 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 = _("MicroSD card is removed")
entry.help_text = SettingsConstants.PERSISTENT_SETTINGS__SD_REMOVED__HELP_TEXT
else:
raise Exception(f"Invalid MicroSD action: {action}")
+39 -18
View File
@@ -128,6 +128,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"
@@ -185,9 +187,10 @@ class SettingsConstants:
SETTING__CAMERA_ROTATION = "camera_rotation"
SETTING__COMPACT_SEEDQR = "compact_seedqr"
SETTING__BIP85_CHILD_SEEDS = "bip85_child_seeds"
SETTING__MESSAGE_SIGNING = "message_signing"
SETTING__PRIVACY_WARNINGS = "privacy_warnings"
SETTING__DIRE_WARNINGS = "dire_warnings"
SETTING__PASSPHRASE_WARNING = "passphrase_warning"
SETTING__QR_BRIGHTNESS_TIPS = "qr_brightness_tips"
SETTING__PARTNER_LOGOS = "partner_logos"
SETTING__DEBUG = "debug"
@@ -240,12 +243,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):
@@ -256,7 +258,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:
@@ -275,18 +277,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:
@@ -332,9 +328,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,
@@ -367,6 +362,7 @@ class SettingsDefinition:
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__LOCALE,
display_name=_("Language"),
abbreviated_name="lang",
type=SettingsConstants.TYPE__SELECT_1,
selection_options=SettingsConstants.ALL_LOCALES,
default_value=SettingsConstants.LOCALE__ENGLISH),
@@ -374,6 +370,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,
@@ -382,12 +379,14 @@ 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,
@@ -400,6 +399,7 @@ class SettingsDefinition:
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,
@@ -431,6 +431,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,
@@ -439,6 +440,7 @@ 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,
@@ -461,11 +463,12 @@ 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,
@@ -475,30 +478,40 @@ class SettingsDefinition:
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__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__PASSPHRASE_WARNING,
display_name=_("Show passphrase warning"),
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),
@@ -514,10 +527,11 @@ 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),
]
@@ -537,6 +551,13 @@ class SettingsDefinition:
return entry
@classmethod
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
def get_defaults(cls) -> dict:
as_dict = {}
Binary file not shown.
Binary file not shown.
+101 -84
View File
@@ -1,40 +1,42 @@
import logging
from gettext import gettext as _
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.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants
from seedsigner.models.encode_qr import EncodeQR
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 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)
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)
self.SCAN_SEED = (_("Scan a seed"), SeedSignerIconConstants.QRCODE)
self.TYPE_12WORD = (_("Enter 12-word seed"), FontAwesomeIconConstants.KEYBOARD)
self.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))
@@ -42,22 +44,18 @@ class PSBTSelectSeedView(View):
# Doesn't look like this seed can sign the current PSBT
button_str += " (?)"
button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue"))
button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT))
button_data.append(SCAN_SEED)
button_data.append(TYPE_12WORD)
button_data.append(TYPE_24WORD)
button_data.append(self.SCAN_SEED)
button_data.append(self.TYPE_12WORD)
button_data.append(self.TYPE_24WORD)
if self.controller.psbt_seed:
if PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.controller.psbt_seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
# skip the seed prompt if a seed was previous selected and has matching input fingerprint
return Destination(PSBTOverviewView)
selected_menu_num = ButtonListScreen(
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)
@@ -70,13 +68,13 @@ 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)
@@ -93,8 +91,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,
@@ -129,23 +129,22 @@ 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,
num_inputs=psbt_parser.num_inputs,
num_self_transfer_outputs=num_self_transfer_outputs,
num_change_outputs=num_change_outputs,
destination_addresses=psbt_parser.destination_addresses,
destination_addresses=psbt_parser.destination_addresses
)
# 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)
@@ -181,6 +180,8 @@ class PSBTUnsupportedScriptTypeWarningView(View):
skip_current_view=True, # Prevent going BACK to WarningViews
)
class PSBTNoChangeWarningView(View):
def run(self):
selected_menu_num = WarningScreen(
@@ -216,14 +217,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)
@@ -250,7 +252,7 @@ class PSBTAddressDetailsView(View):
if not psbt_parser:
# Should not be able to get here
return Destination(MainMenuView)
raise Exception("Routing error")
# TRANSLATOR_NOTE: Future-tense used to indicate that this transaction will send this amount, as opposed to "Send" on its own which could be misread as an instant command (e.g. "Send Now").
title = _("Will Send")
@@ -264,34 +266,36 @@ class PSBTAddressDetailsView(View):
# TRANSLATOR_NOTE: Short for "Next step"
button_data.append(_("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})
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})
else:
# There's no change output to verify. Move on to sign the PSBT.
return Destination(PSBTFinalizeView)
class PSBTChangeDetailsView(View):
"""
"""
NEXT = "Next"
VERIFY_MULTISIG = "Verify Multisig Change"
def __init__(self, change_address_num):
super().__init__()
self.change_address_num = change_address_num
@@ -331,15 +335,15 @@ class PSBTChangeDetailsView(View):
is_change_derivation_path = int(derivation_path.split("/")[-2]) == 1
derivation_path_addr_index = int(derivation_path.split("/")[-1])
NEXT = _("Next")
self.NEXT = _("Next")
if is_change_derivation_path:
# TRANSLATOR_NOTE: The amount you're receiving back from the transaction
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})"
@@ -348,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.NEXT]
else:
# Single sig
@@ -361,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()
@@ -394,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()
@@ -402,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"),
@@ -413,19 +419,19 @@ 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:
if self.change_address_num < psbt_parser.num_change_outputs - 1:
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": self.change_address_num + 1})
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)
@@ -466,19 +472,27 @@ class PSBTAddressVerificationFailedView(View):
class PSBTFinalizeView(View):
"""
"""
APPROVE_PSBT = "Approve PSBT"
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
psbt: PSBT = self.controller.psbt
self.APPROVE_PSBT = _("Approve PSBT")
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
sig_cnt = PSBTParser.sig_count(psbt)
psbt.sign_with(psbt_parser.root)
@@ -494,9 +508,6 @@ class PSBTFinalizeView(View):
self.controller.psbt = trimmed_psbt
return Destination(PSBTSignedQRDisplayView)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
class PSBTSignedQRDisplayView(View):
@@ -507,7 +518,7 @@ class PSBTSignedQRDisplayView(View):
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()
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).
@@ -516,22 +527,28 @@ class PSBTSignedQRDisplayView(View):
class PSBTSigningErrorView(View):
SELECT_DIFF_SEED = "Select Diff Seed"
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
self.SELECT_DIFF_SEED = _("Select Diff Seed")
if not psbt_parser:
# Should not be able to get here
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:
+105 -30
View File
@@ -1,30 +1,70 @@
import json
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 .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
from seedsigner.views.settings_views import SettingsIngestSettingsQRView
from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, OptionDisabledView, View, Destination
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
wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
self.decoder = DecodeQR(wordlist_language_code=wordlist_language_code)
# Start the live preview and background QR reading
ScanScreen(decoder=self.decoder).display()
self.run_screen(
ScanScreen,
instructions_text=self.instructions_text,
decoder=self.decoder
)
# 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
return Destination(NotYetImplementedView)
@@ -33,7 +73,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
@@ -49,13 +89,8 @@ class ScanView(View):
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
@@ -100,31 +135,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:
raise Exception("QRCode not recognized or not yet supported.")
# 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
+9 -7
View File
@@ -91,7 +91,6 @@ class OpeningSplashScreen(LogoScreen):
class ScreensaverScreen(LogoScreen):
def __init__(self, buttons):
super().__init__()
@@ -143,9 +142,9 @@ class ScreensaverScreen(LogoScreen):
# never gives up the lock until it returns.
with self.renderer.lock:
try:
while True:
while self._is_running:
if self.buttons.has_any_input() or self.buttons.override_ind:
return self.stop()
break
# Must crop the image to the exact display size
crop = self.image.crop((
@@ -178,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()
# 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
File diff suppressed because it is too large Load Diff
+69 -27
View File
@@ -1,14 +1,24 @@
from gettext import gettext as _
import logging
from seedsigner.gui.components import SeedSignerIconConstants
from seedsigner.hardware.microsd import MicroSD
from seedsigner.gui.components import SeedSignerCustomIconConstants
from .view import View, Destination, MainMenuView
from seedsigner.gui.components import SeedSignerIconConstants
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__)
from .view import View, Destination, BackStackView, MainMenuView
class SettingsMenuView(View):
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
@@ -19,8 +29,8 @@ class SettingsMenuView(View):
def run(self):
IO_TEST = _("I/O test")
DONATE = _("Donate")
self.IO_TEST = _("I/O test")
self.DONATE = _("Donate")
settings_entries = SettingsDefinition.get_settings_entries(
visibility=self.visibility
@@ -38,35 +48,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
screen = ButtonListScreen(
selected_menu_num = self.run_screen(
ButtonListScreen,
title=title,
is_button_text_centered=False,
button_data=button_data,
selected_button=selected_button,
scroll_y_initial_offset=self.initial_scroll,
)
selected_menu_num = screen.display()
# Preserve our scroll position in this Screen so we can return
initial_scroll = screen.buttons[0].scroll_y
initial_scroll = self.screen.buttons[0].scroll_y
if selected_menu_num == RET_CODE__BACK_BUTTON:
if self.visibility == SettingsConstants.VISIBILITY__GENERAL:
@@ -77,12 +87,12 @@ 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:
@@ -95,10 +105,10 @@ class SettingsEntryUpdateSelectionView(View):
Handles changes to all selection-type settings (Multiselect, SELECT_1,
Enabled/Disabled, etc).
"""
def __init__(self, attr_name: str, parent_initial_scroll: int = 0):
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
@@ -112,6 +122,7 @@ class SettingsEntryUpdateSelectionView(View):
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)
@@ -120,17 +131,18 @@ 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(
@@ -178,7 +190,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)
@@ -187,7 +229,7 @@ class SettingsEntryUpdateSelectionView(View):
****************************************************************************"""
class IOTestView(View):
def run(self):
settings_screens.IOTestScreen().display()
self.run_screen(settings_screens.IOTestScreen)
return Destination(SettingsMenuView)
@@ -195,6 +237,6 @@ class IOTestView(View):
class DonateView(View):
def run(self):
settings_screens.DonateScreen().display()
self.run_screen(settings_screens.DonateScreen)
return Destination(SettingsMenuView)
+179 -133
View File
@@ -1,59 +1,71 @@
from dataclasses import dataclass
import hashlib
import os
import time
from gettext import gettext as _
from embit.descriptor import Descriptor
from PIL import Image
from PIL.ImageOps import autocontrast
from seedsigner.controller import Controller
from seedsigner.gui.screens.screen import LoadingScreenThread, QRDisplayScreen
from seedsigner.hardware.camera import Camera
from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerCustomIconConstants
from seedsigner.controller import Controller
from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerIconConstants
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen)
from seedsigner.gui.screens.tools_screens import ToolsCalcFinalWordDoneScreen, ToolsCalcFinalWordFinalizePromptScreen, ToolsCalcFinalWordScreen, ToolsCoinFlipEntryScreen, ToolsDiceEntropyEntryScreen, ToolsImageEntropyFinalImageScreen, ToolsImageEntropyLivePreviewScreen, ToolsAddressExplorerAddressTypeScreen
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 EncodeQR
from seedsigner.models.qr_type import QRType
from seedsigner.models.seed import Seed
from seedsigner.models.settings_definition import SettingsConstants
from seedsigner.views.seed_views import SeedDiscardView, SeedFinalizeView, SeedMnemonicEntryView, SeedWordsWarningView, SeedExportXpubScriptTypeView
from seedsigner.views.seed_views import SeedDiscardView, SeedFinalizeView, SeedMnemonicEntryView, SeedOptionsView, SeedWordsWarningView, SeedExportXpubScriptTypeView
from .view import View, Destination, BackStackView
class ToolsMenuView(View):
IMAGE = ("New seed", FontAwesomeIconConstants.CAMERA)
DICE = ("New seed", FontAwesomeIconConstants.DICE)
KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD)
EXPLORER = "Address Explorer"
ADDRESS = "Verify address"
def run(self):
IMAGE = (_(" New seed"), FontAwesomeIconConstants.CAMERA)
DICE = (_("New seed"), FontAwesomeIconConstants.DICE)
KEYBOARD = (_("Calc 12th/24th word"), FontAwesomeIconConstants.KEYBOARD)
EXPLORER = _("Address Explorer")
button_data = [IMAGE, DICE, KEYBOARD, EXPLORER]
screen = ButtonListScreen(
self.IMAGE = (_("New seed"), FontAwesomeIconConstants.CAMERA)
self.DICE = (_("New seed"), FontAwesomeIconConstants.DICE)
self.KEYBOARD = (_("Calc 12th/24th word"), FontAwesomeIconConstants.KEYBOARD)
self.EXPLORER = _("Address Explorer")
button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.EXPLORER, self.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] == EXPLORER:
elif button_data[selected_menu_num] == self.EXPLORER:
return Destination(ToolsAddressExplorerSelectSourceView)
elif button_data[selected_menu_num] == self.ADDRESS:
from seedsigner.views.scan_views import ScanAddressView
return Destination(ScanAddressView)
"""****************************************************************************
Image entropy Views
@@ -74,6 +86,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))
@@ -216,9 +229,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))
@@ -233,28 +244,33 @@ 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")
self.TWELVE = _("12 words")
self.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))
@@ -307,8 +323,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
@@ -323,70 +338,76 @@ 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):
# l10n_note already exists.
NEXT = _("Next")
button_data = [NEXT]
selected_menu_num = ToolsCalcFinalWordScreen(
selected_menu_num = self.run_screen(
ToolsCalcFinalWordScreen,
# TRANSLATOR_NOTE: label to calculate the last word of a BIP-39 mnemonic seed phrase
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)
@@ -429,29 +450,31 @@ class ToolsCalcFinalWordDoneView(View):
Address Explorer Views
****************************************************************************"""
class ToolsAddressExplorerSelectSourceView(View):
def run(self):
SCAN_SEED = (_("Scan a seed"), FontAwesomeIconConstants.QRCODE)
SCAN_DESCRIPTOR = (_("Scan wallet descriptor"), FontAwesomeIconConstants.QRCODE)
TYPE_12WORD = (_("Enter 12-word seed"), FontAwesomeIconConstants.KEYBOARD)
TYPE_24WORD = (_("Enter 24-word seed"), FontAwesomeIconConstants.KEYBOARD)
button_data = []
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)
def run(self):
seeds = self.controller.storage.seeds
self.SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE)
self.SCAN_DESCRIPTOR = ("Scan wallet descriptor", SeedSignerIconConstants.QRCODE)
self.TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
self.TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
button_data = []
for seed in seeds:
button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue"))
button_data.append(SCAN_SEED)
button_data.append(SCAN_DESCRIPTOR)
button_data.append(TYPE_12WORD)
button_data.append(TYPE_24WORD)
selected_menu_num = ButtonListScreen(
button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT))
button_data = button_data + [self.SCAN_SEED, self.SCAN_DESCRIPTOR, self.TYPE_12WORD, self.TYPE_24WORD]
selected_menu_num = self.run_screen(
ButtonListScreen,
title=_("Address Explorer"),
button_data=button_data,
is_button_text_centered=False,
is_bottom_list=True,
).display()
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -471,13 +494,17 @@ class ToolsAddressExplorerSelectSourceView(View):
)
)
elif button_data[selected_menu_num] in [SCAN_SEED, SCAN_DESCRIPTOR]:
from seedsigner.views.scan_views import ScanView
return Destination(ScanView)
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] in [TYPE_12WORD, TYPE_24WORD]:
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] == 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)
@@ -486,6 +513,10 @@ class ToolsAddressExplorerSelectSourceView(View):
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
@@ -535,10 +566,9 @@ class ToolsAddressExplorerAddressTypeView(View):
data = self.controller.address_explorer_data
# TRANSLATOR_NOTE: label for addresses where others send us incoming payments
RECEIVE = _("Receive Addresses")
self.RECEIVE = _("Receive Addresses")
# TRANSLATOR_NOTE: label for addresses that collect the change from our own outgoing payments
CHANGE = _("Change Addresses")
button_data = [RECEIVE, CHANGE]
self.CHANGE = _("Change Addresses")
wallet_descriptor_display_name = None
if "wallet_descriptor" in data:
@@ -547,19 +577,30 @@ class ToolsAddressExplorerAddressTypeView(View):
script_type = data["script_type"] if "script_type" in data else None
selected_menu_num = ToolsAddressExplorerAddressTypeScreen(
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,
).display()
)
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 [RECEIVE, CHANGE]:
return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=button_data[selected_menu_num] == CHANGE))
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))
@@ -574,21 +615,23 @@ class ToolsAddressExplorerAddressListView(View):
def run(self):
self.loading_screen = None
try:
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"
addresses = []
button_data = []
data = self.controller.address_explorer_data
addrs_per_screen = 10
if addr_storage_key in data and len(data[addr_storage_key]) >= self.start_index + addrs_per_screen:
# We already calculated this range addresses; just retrieve them
addresses = data[addr_storage_key][self.start_index:self.start_index + addrs_per_screen]
addr_storage_key = "receive_addrs"
if self.is_change:
addr_storage_key = "change_addrs"
else:
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
# TRANSLATOR_NOTE: a status message that our payment addresses are being calculated
self.loading_screen = LoadingScreenThread(text=_("Calculating addrs..."))
self.loading_screen.start()
@@ -618,35 +661,36 @@ class ToolsAddressExplorerAddressListView(View):
else:
raise Exception("Single sig descriptors not yet supported")
for i, address in enumerate(addresses):
cur_index = i + self.start_index
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, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT))
screen = ButtonListScreen(
title=_("Receive Addrs") if not self.is_change else _("Change Addrs"),
button_data=button_data,
button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
button_font_size=GUIConstants.get_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,
)
finally:
# Everything is set. Stop the loading screen
if self.loading_screen:
finally:
# Everything is set. Stop the loading screen
self.loading_screen.stop()
selected_menu_num = screen.display()
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=_("Receive Addrs") if not self.is_change else _("Change Addrs"),
button_data=button_data,
button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
button_font_size=GUIConstants.get_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)
@@ -656,7 +700,7 @@ class ToolsAddressExplorerAddressListView(View):
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 = screen.buttons[0].scroll_y
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)
@@ -674,10 +718,12 @@ class ToolsAddressExplorerAddressView(View):
def run(self):
from seedsigner.gui.screens.screen import QRDisplayScreen
qr_encoder = EncodeQR(qr_type=QRType.BITCOIN_ADDRESS, bitcoin_address=self.address)
QRDisplayScreen(
self.run_screen(
QRDisplayScreen,
qr_encoder=qr_encoder,
).display()
)
# 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)
+232 -60
View File
@@ -1,12 +1,13 @@
from dataclasses import dataclass
from gettext import gettext as _
from typing import List
from typing import Type
from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants
from seedsigner.gui.screens import RET_CODE__POWER_BUTTON
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, 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
from seedsigner.models import Settings
class BackStackView:
@@ -43,11 +44,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()
@@ -57,14 +62,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")
@@ -74,10 +122,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):
@@ -94,12 +142,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):
@@ -122,53 +182,67 @@ 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):
self.SCAN = ("Scan", SeedSignerIconConstants.SCAN)
self.SEEDS = ("Seeds", SeedSignerIconConstants.SEEDS)
self.TOOLS = ("Tools", SeedSignerIconConstants.TOOLS)
self.SETTINGS = ("Settings", SeedSignerIconConstants.SETTINGS)
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(
self.RESET = ("Restart", SeedSignerIconConstants.RESTART)
self.POWER_OFF = ("Power Off", SeedSignerIconConstants.POWER)
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)
@@ -177,7 +251,7 @@ class RestartView(View):
def run(self):
thread = RestartView.DoResetThread()
thread.start()
ResetScreen().display()
self.run_screen(ResetScreen)
class DoResetThread(BaseThread):
@@ -201,12 +275,12 @@ class RestartView(View):
class PowerOffView(View):
def run(self):
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
PowerOffNotRequiredScreen().display()
self.run_screen(PowerOffNotRequiredScreen)
return Destination(BackStackView)
else:
thread = PowerOffView.PowerOffThread()
thread.start()
PowerOffScreen().display()
self.run_screen(PowerOffScreen)
class PowerOffThread(BaseThread):
@@ -223,29 +297,127 @@ class NotYetImplementedView(View):
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!"),
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 = Destination(MainMenuView, clear_history=True)
def run(self):
DireWarningScreen(
self.title = _("Error"),
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,
).display()
allow_text_overflow=True, # Fit what we can, let the rest go off the edges
)
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)
+8 -2
View File
@@ -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
```
+237
View File
@@ -0,0 +1,237 @@
import sys
from dataclasses import dataclass
from 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, View
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()
@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 FlowTestRunScreenNotExecutedException(FlowBasedTestException):
""" The View's run_screen() method was not called but the FlowStep expected it to need user input """
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:
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}")
# Run the optional pre-run function to modify the View.
if cur_flow_step.before_run:
cur_flow_step.before_run(destination.view)
if cur_flow_step.is_redirect:
# The current View is going to auto-redirect without calling run_screen(),
# so we need to remove the current step from the sequence before the
# View.run() call below.
sequence.pop(0)
if destination.view.has_redirect:
# TODO: Migrate all View redirects to use `View.set_redirect()`
# in their `__init__()` rather than `run()` and then refactor
# here to explicitly require `has_redirect` to be True.
# For now: Support the newer `set_redirect()` routing while
# still letting redirects get returned by `View.run()` further
# below.
return destination.view.get_redirect()
# Some Views reach into their Screen's variables directly (e.g.
# Screen.buttons to preserve the scroll position), so we need to mock out the
# Screen instance that is created by the View.
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 (cur_flow_step.button_data_selection or cur_flow_step.screen_return_value is not None) and mock_run_screen.call_count == prev_mock_run_screen_call_count:
# The FlowStep was expecting some kind of user interaction, but the View
# never called run_screen().
raise FlowTestRunScreenNotExecutedException(f"View.run_screen() was not run for {destination.View_cls.__name__}")
return destination
def run_screen(view: View, *args, **kwargs):
""" Replaces View.run_screen() """
# Return the return value specified in the test sequence and
# remove the completed test step from the sequence.
flow_step = sequence.pop(0)
if 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 flow_step.button_data_selection not in kwargs.get("button_data") and flow_step.button_data_selection not in [RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON]:
raise FlowTestInvalidButtonDataSelectionException(f"'{flow_step.button_data_selection}' not found in button_data: {kwargs.get('button_data')}")
return kwargs.get("button_data").index(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(flow_step.screen_return_value) in [StopFlowBasedTest, FlowBasedTestException]:
raise flow_step.screen_return_value
return 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)
+2 -1
View File
@@ -3,6 +3,7 @@
From the project root, run:
```
pytest tests/screenshot_generator/generator.py --locale en
pytest tests/screenshot_generator/generator.py
```
By default it writes the screenshots to a dir that's parallel to the project root: `seedsigner-screenshots/<locale>`.
Writes the screenshots to a dir in the project root: `seedsigner-screenshots`.
+132 -44
View File
@@ -1,25 +1,45 @@
import embit
import os
import sys
import time
import pathlib
import pytest
import shutil
from mock import Mock, patch
from mock import Mock, patch, MagicMock
from seedsigner.helpers import embit_utils
from seedsigner.models.settings import Settings
# 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.buttons import HardwareButtons
from seedsigner.hardware.camera import Camera
from seedsigner.hardware.microsd import MicroSD
from seedsigner.models.decode_qr import DecodeQR
from seedsigner.models.qr_type import QRType
from seedsigner.models.seed import Seed
from seedsigner.models.settings import Settings
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
from seedsigner.views import (MainMenuView, PowerOptionsView, RestartView, psbt_views, scan_views, seed_views,
settings_views, tools_views)
from seedsigner.views.view import View
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
# Ignore Pillow 10 DeprecationWarnings "use getbbox() or getlength() instead of getsize()"
import warnings; warnings.warn = lambda *args, **kwargs: None
def test_generate_screenshots(target_locale):
@@ -30,12 +50,8 @@ def test_generate_screenshots(target_locale):
When the `Renderer` instance is needed, we patch in our own test-only
`ScreenshotRenderer`.
"""
# Disable hardware dependencies by essentially wiping out this class
HardwareButtons.get_instance = Mock()
Camera.get_instance = Mock()
# Prep the ScreenshotRenderer that will be patched over the normal Renderer
screenshot_root = "/home/pi/seedsigner-screenshots"
screenshot_root = os.path.join(os.getcwd(), "seedsigner-screenshots")
ScreenshotRenderer.configure_instance()
screenshot_renderer: ScreenshotRenderer = ScreenshotRenderer.get_instance()
@@ -43,6 +59,9 @@ def test_generate_screenshots(target_locale):
Renderer.configure_instance = Mock()
Renderer.get_instance = Mock(return_value=screenshot_renderer)
# Additional mocks needed
PowerOffView.PowerOffThread = Mock() # Don't let this View actually send the `shutdown` command!
controller = Controller.get_instance()
# Set up some test data that we'll need in the `Controller` for certain Views
@@ -55,6 +74,13 @@ def test_generate_screenshots(target_locale):
controller.storage.seeds.append(seed_12)
controller.storage.seeds.append(seed_12b)
controller.storage.set_pending_seed(seed_24)
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="""
@@ -72,24 +98,20 @@ def test_generate_screenshots(target_locale):
with open(messages_source_path, 'r') as messages_source_file:
num_source_messages = messages_source_file.read().count("msgid \"") - 1
def screencap_view(view_cls: View, view_name: str, view_args: dict={}):
screenshot_renderer.set_screenshot_filename(f"{view_name}.png")
try:
print(f"Running {view_name}")
view_cls(**view_args).run()
except ScreenshotComplete:
# Slightly hacky way to exit ScreenshotRenderer as expected
pass
print(f"Completed {view_name}")
except Exception as e:
# Something else went wrong
print(repr(e))
raise e
# Message signing data
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)
#jdlcdl so we get a choice for transcribe seed qr format
# so we get a choice for transcribe seed qr format
controller.settings.set_value(
attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR,
value=SettingsConstants.OPTION__ENABLED
@@ -99,17 +121,20 @@ def test_generate_screenshots(target_locale):
continue
settings_views_list.append((settings_views.SettingsEntryUpdateSelectionView, dict(attr_name=settings_entry.attr_name), f"SettingsEntryUpdateSelectionView_{settings_entry.attr_name}"))
settings_views_list.append(settings_views.IOTestView)
settings_views_list.append(settings_views.DonateView)
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,
#main_menu_views.PowerOffView,
(scan_views.SettingsUpdatedView, dict(config_name="Keith's Settings")),
PowerOffView,
],
"Seed Views": [
seed_views.SeedsMenuView,
@@ -117,9 +142,9 @@ def test_generate_screenshots(target_locale):
seed_views.SeedMnemonicEntryView,
seed_views.SeedMnemonicInvalidView,
seed_views.SeedFinalizeView,
seed_views.SeedPassphraseWarningView,
seed_views.SeedAddPassphraseView,
seed_views.SeedReviewPassphraseView,
(seed_views.SeedOptionsView, dict(seed_num=0)),
(seed_views.SeedBackupView, dict(seed_num=0)),
(seed_views.SeedExportXpubSigTypeView, dict(seed_num=0)),
@@ -128,11 +153,13 @@ def test_generate_screenshots(target_locale):
(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")),
@@ -150,7 +177,6 @@ def test_generate_screenshots(target_locale):
# Screenshot can't render live preview screens
# (seed_views.SeedTranscribeSeedQRConfirmScanView, dict(seed_num=0)),
#jdlcdl todo
#(seed_views.AddressVerificationStartView, dict(address=, script_type="nat", network="M")),
#seed_views.AddressVerificationSigTypeView,
#seed_views.SeedSingleSigAddressVerificationSelectSeedView,
@@ -160,9 +186,12 @@ def test_generate_screenshots(target_locale):
seed_views.LoadMultisigWalletDescriptorView,
seed_views.MultisigWalletDescriptorView,
(seed_views.SeedDiscardView, dict(seed_num=0)),
seed_views.SeedSignMessageConfirmMessageView,
seed_views.SeedSignMessageConfirmAddressView,
],
"PSBT Views": [
psbt_views.PSBTSelectSeedView,
psbt_views.PSBTSelectSeedView, # this will fail, be rerun below
psbt_views.PSBTOverviewView,
psbt_views.PSBTUnsupportedScriptTypeWarningView,
psbt_views.PSBTNoChangeWarningView,
@@ -176,28 +205,74 @@ def test_generate_screenshots(target_locale):
(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,
# jdlcdl todo
#(tools_views.ToolsCalcFinalWordShowFinalWordView, dict(coin_flips=3)),
(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,
# jdlcdl todo
#tools_views.ToolsAddressExplorerAddressView,
],
"Settings Views": settings_views_list,
"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",
#)),
]
}
def screencap_view(view_cls: View, view_name: str, view_args: dict={}, toast_thread: BaseToastOverlayManagerThread = None):
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()
locales = []
if target_locale is None:
locales = SettingsConstants.ALL_LOCALES
@@ -221,11 +296,14 @@ def test_generate_screenshots(target_locale):
locale_translations = translation_file.read()
num_locale_translations = locale_translations.count("msgid \"") - locale_translations.count("""msgstr ""\n\n""") - 1
locale_readme += f"## Translation progress: {num_locale_translations / num_source_messages:.1%}\n\n"
locale_readme += f"[{display_name} messages.po catalog](messages.po)\n"
if locale != "en":
locale_readme += f"## Translation progress: {num_locale_translations / num_source_messages:.1%}\n\n"
locale_readme += f"[{display_name} messages.po catalog](messages.po)\n"
locale_readme += "---\n\n"
for section_name, screenshot_list in screenshot_sections.items():
subdir = section_name.lower().replace(" ", "_")
screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, locale, subdir))
locale_readme += "\n\n---\n\n"
locale_readme += f"## {section_name}\n\n"
locale_readme += """<table style="border: 0;">"""
@@ -237,21 +315,31 @@ def test_generate_screenshots(target_locale):
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_name, view_args)
locale_readme += """<table align="left" style="border: 1px solid gray;">"""
locale_readme += f"""<tr><td align="center">{view_name}<br/><br/><img src="{view_name}.png"></td></tr>"""
locale_readme += """</table>"""
screencap_view(view_cls, view_name, view_args, toast_thread=toast_thread)
locale_readme += """ <table align="left" style="border: 1px solid gray;">"""
locale_readme += f"""<tr><td align="center">{view_name}<br/><br/><img src="{subdir}/{view_name}.png"></td></tr>"""
locale_readme += """</table>\n"""
locale_readme += "</td></tr></table>"
with open(os.path.join(screenshot_renderer.screenshot_path, "README.md"), 'w') as readme_file:
# many screens don't work, leaving a missing image, re-run here for now
controller.psbt_seed = None
screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, locale, "psbt_views"))
screencap_view(psbt_views.PSBTSelectSeedView, 'PSBTSelectSeedView', {})
with open(os.path.join(screenshot_root, locale, "README.md"), 'w') as readme_file:
readme_file.write(locale_readme)
shutil.copy(translated_messages_path, os.path.join(screenshot_renderer.screenshot_path, "messages.po"))
if locale != "en":
shutil.copy(translated_messages_path, os.path.join(screenshot_root, locale, "messages.po"))
# Write the main README; ensure it writes all locales, not just the one that may
+1 -1
View File
@@ -1,6 +1,6 @@
import pytest
from mock import MagicMock
from seedsigner.models import Seed
from seedsigner.models.seed import Seed
from embit import bip39
from seedsigner.models.settings import SettingsConstants
+91 -94
View File
@@ -1,117 +1,114 @@
import configparser
import pytest
from mock import MagicMock
from seedsigner.hardware.microsd import MicroSD
# Must import this before the Controller
from base import BaseTest
from seedsigner.controller import Controller
from seedsigner.models.settings_definition import SettingsConstants
class TestController(BaseTest):
@pytest.fixture()
def reset_controller():
"""fixture to setup, then yield to run test, then tear down"""
def test_reset_controller(self):
""" The reset_controller util should completely reset the Controller singleton """
controller = Controller.get_instance()
controller.address_explorer_data = "foo"
# setup
Controller.configure_instance(disable_hardware=True)
# yield to run a single test
yield
# tear down
MicroSD._instance = None
Controller._instance = None
def test_singleton_init_fails(reset_controller):
""" The Controller should not allow any code to instantiate it via Controller() """
with pytest.raises(Exception):
c = Controller()
BaseTest.reset_controller()
controller = Controller.get_instance()
assert controller.address_explorer_data is None
def test_singleton_get_instance_preserves_state(reset_controller):
""" 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_singleton_init_fails(self):
""" The Controller should not allow any code to instantiate it via Controller() """
with pytest.raises(Exception):
c = Controller()
def test_handle_exception(reset_controller):
""" Handle exceptions that get caught by the controller """
def test_handle_exception(reset_controller):
""" Handle exceptions that get caught by the controller """
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:
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"]
# 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:
raise exception_type(exception_msg)
assert exception_msg in error[2]
else:
raise exception_type()
except Exception as e:
error = controller.handle_exception(e).view_args["error"]
assert error[2] == ""
# 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()
# 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)
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_missing_settings_get_defaults(reset_controller):
""" Should gracefully handle all missing fields from `settings.json` """
def test_singleton_get_instance_preserves_state(self):
""" Changes to the Controller singleton should be preserved across calls to get_instance() """
controller = Controller.get_instance()
# Initialize the instance and verify that it read the config settings
controller = Controller.get_instance()
assert controller.unverified_address is None
# 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
# Change a value in the instance...
controller.unverified_address = "123abc"
# 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]
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__0
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__PRIVACY_WARNINGS) == SettingsConstants.OPTION__ENABLED
assert controller.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED
assert controller.settings.get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED
# ...get a new copy of the instance and confirm change
controller = Controller.get_instance()
assert controller.unverified_address == "123abc"
# Hidden Settings defaults
assert controller.settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS) == 189
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__LOCALE) == SettingsConstants.LOCALE__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]
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__PRIVACY_WARNINGS) == SettingsConstants.OPTION__ENABLED
assert controller.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == 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
+4 -4
View File
@@ -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
+402
View File
@@ -0,0 +1,402 @@
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'",
# 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,
# 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/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",
# 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 = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
# keystore2 = 0x11*16 = 'baby mass dust captain baby mass dust captain baby mass dust casino'
# keystore3 = 0x22*16 = '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, not supported
("sh(sortedmulti(2,[8d55ff0d/45h]tpubDANogJ2yfnizHwX7fSi5kUVzybyuPXDhgHB2TR9TUvkSLZFW73cRq4STKFDpx7qjJJiisyq82tbu4CeiYtmKEmT1xoCq9P8BPvXV31HUh6d/{0,1}/*,[0be174ee/45h]tpubDBkeVF2tDNT1Pz7L47iJeBB6RokU12LX6x4E6Ph8T89hmjQfB77q1AMyGwL8qpREVGq9sCJEbWwmnemwNTxnpxGn1di7BGy8jx9wEi5Vahu/{0,1}/*,[73c5da0a/45h]tpubDBKsGC1UqBDNvx9aivFmxZNgeZTUnmsCFGhWrqkLzucUCDePvbWWm3n8tAaAwMmxBG2ihdKCG9fzBdUnMxKx5PrkiqSZFi6Vkv6msUs9ddN/{0,1}/*))#p5t8sa8c", 0, False, "test"): Exception,
# 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",
# 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])
+2 -3
View File
@@ -1,6 +1,5 @@
import pytest
from mock import MagicMock
from seedsigner.models import EncodeQR, QRType
from seedsigner.models.encode_qr import EncodeQR
from seedsigner.models.qr_type import QRType
from embit import psbt
from binascii import a2b_base64
+131
View File
@@ -0,0 +1,131 @@
import pytest
# Must import test base before the Controller
from base import FlowTest, FlowStep, FlowTestUnexpectedViewException, FlowTestInvalidButtonDataSelectionException, FlowTestRunScreenNotExecutedException
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.models.settings_definition import SettingsConstants
from seedsigner.views.seed_views import SeedBackupView, SeedMnemonicEntryView, SeedOptionsView, SeedWordsWarningView
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! Should raise an AssertionError.
])
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_FlowTestRunScreenNotExecutedException(self):
"""
Ensure that the FlowTest will raise a FlowTestRunScreenNotExecutedException if the next
View in the sequence doesn't call its View.run_screen().
"""
# Disable dire warnings so that the SeedWordsWarningView won't execute its run_screen()
self.settings.set_value(SettingsConstants.SETTING__DIRE_WARNINGS, SettingsConstants.OPTION__DISABLED)
self.controller.storage.set_pending_seed(Seed(mnemonic=["bacon"] * 24))
self.controller.storage.finalize_pending_seed()
with pytest.raises(FlowTestRunScreenNotExecutedException):
self.run_sequence(
initial_destination_view_args=dict(seed_num=0),
sequence=[
FlowStep(SeedOptionsView, button_data_selection=SeedOptionsView.BACKUP),
FlowStep(SeedBackupView, button_data_selection=SeedBackupView.VIEW_WORDS),
FlowStep(SeedWordsWarningView, screen_return_value=0),
],
)
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),
])
+93
View File
@@ -0,0 +1,93 @@
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
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_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.NEXT),
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=seed_views.SeedFinalizeView.PASSPHRASE),
FlowStep(seed_views.SeedAddPassphraseView, screen_return_value="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.NEXT),
FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT),
FlowStep(psbt_views.PSBTSignedQRDisplayView),
FlowStep(MainMenuView),
])
+505
View File
@@ -0,0 +1,505 @@
from typing import Callable
import pytest
# Must import test base before the Controller
from base import BaseTest, FlowTest, FlowStep
from base import FlowTestRunScreenNotExecutedException, FlowTestInvalidButtonDataSelectionException
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
from seedsigner.models.settings import Settings, SettingsConstants
from seedsigner.models.seed import Seed
from seedsigner.views.view import ErrorView, MainMenuView, OptionDisabledView, RemoveMicroSDWarningView, View, NetworkMismatchErrorView
from seedsigner.views import seed_views, scan_views, settings_views, tools_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_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_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])
# test that multisig is not an option via exception raised when redirected to next step instead of having a choice
with pytest.raises(FlowTestRunScreenNotExecutedException) 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, button_data_selection=disabled_sig),
]
)
# 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_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"
SHORT_MESSAGE = "I attest that I control this bitcoin address blah blah blah"
MULTIPAGE_MESSAGE = """Chancellor on brink of second bailout for banks
Billions may be needed as lending squeeze tightens
Alistair Darling has been forced to consider a second bailout for banks as the lending drought worsens.
The Chancellor will decide within weeks whether to pump billions more into the economy as evidence mounts that the £37 billion part-nationalisation last year has failed to keep credit flowing. Options include cash injections, offering banks cheaper state guarantees to raise money privately or buying up “toxic assets”, The Times has learnt."""
def load_seed_into_decoder(self, view: scan_views.ScanView):
view.decoder.add_data("0000" * 11 + "0003")
def load_signmessage_into_decoder(self, view:View, derivation_path: str, message: str):
view.decoder.add_data(f"signmessage {derivation_path} ascii:{message}")
def load_short_message_into_decoder(self, view: View):
self.load_signmessage_into_decoder(view, self.MAINNET_DERIVATION_PATH, self.SHORT_MESSAGE)
def load_testnet_message_into_decoder(self, view: View):
self.load_signmessage_into_decoder(view, self.TESTNET_DERIVATION_PATH, self.SHORT_MESSAGE)
def load_multipage_message_into_decoder(self, view: View):
self.load_signmessage_into_decoder(view, self.MAINNET_DERIVATION_PATH, self.MULTIPAGE_MESSAGE)
def inject_mesage_as_paged_message(self, view: View):
# Because the Screen won't actually run, we have to do the Screen's work here
from seedsigner.gui.components import reflow_text_into_pages, GUIConstants
paged = reflow_text_into_pages(
text=self.controller.sign_message_data["message"],
width=240 - 2*GUIConstants.EDGE_PADDING,
height=240 - GUIConstants.TOP_NAV_HEIGHT - 3*GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT,
)
self.controller.sign_message_data["paged_message"] = paged
def test_sign_message_flow(self):
"""
Should scan a `signmessage` QR and complete the message review, address review,
and signing flow.
"""
# Ensure message signing is enabled
self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED)
# 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),
])
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, is_redirect=True),
FlowStep(settings_views.SettingsEntryUpdateSelectionView),
]
)
# Now test exiting to Main Menu
self.run_sequence(
sequence + [
FlowStep(OptionDisabledView, button_data_selection=OptionDisabledView.DONE, is_redirect=True),
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

Some files were not shown because too many files have changed in this diff Show More