Merge branch 'dev' into qr-brightness-tips

This commit is contained in:
Nick Klockenga
2023-07-20 16:10:24 -04:00
committed by GitHub
27 changed files with 2115 additions and 534 deletions
+15 -13
View File
@@ -17,7 +17,7 @@
# 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).
@@ -26,20 +26,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
@@ -52,10 +54,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!
---------------
+165 -73
View File
@@ -15,11 +15,12 @@ For the following steps you'll need to either connect a keyboard & monitor to th
### Configure the Pi
First things first, verify that you are using the correct version of the Raspberry Pi Lite operating system by typing the command:
```
```bash
cat /etc/os-release
```
The output of this command should match the following text:
```
```bash
PRETTY_NAME="Raspbian GNU/Linux 10 (buster)"
NAME="Raspbian GNU/Linux"
VERSION_ID="10"
@@ -33,7 +34,7 @@ BUG_REPORT_URL="http://www.raspbian.org/RaspbianBugs"
```
Now launch the Raspberry Pi's System Configuration tool using the command:
```
```bash
sudo raspi-config
```
@@ -45,6 +46,8 @@ Set the following:
* `Locale`: arrow up and down through the list and select or deselect languages with the spacebar.
* Deselect the default language option that is selected
* Select `en_US.UTF-8 UTF-8` for US English
* Use the `TAB` button to select `Ok` and press `ENTER`
* On the next screen select `en_US.UTF-8` for the default locale
* You will also need to configure the WiFi settings if you are using the #1 option above to connect to the internet
When you exit the System Configuration tool, you will be prompted to reboot the system; allow the system to reboot and continue with these instructions.
@@ -52,18 +55,55 @@ When you exit the System Configuration tool, you will be prompted to reboot the
### Change the default password
Change the system's default password from the default "raspberry". Run the command:
```
```bash
passwd
```
You will be prompted to enter the current password ("raspberry") and then to enter a new password twice. In our prepared release image, the password used is `AirG@pped!`.
### Install dependencies
Copy this entire box and run it as one command (will take 15-20min to complete):
### Install python3.10
```bash
# install compiler dependencies; takes ~1 minute on a Pi Zero 1.3
# * openssl, libssl-dev: ssl support when pip fetches packages
# * libsqlite3-dev: required by `coverage`
sudo apt update && sudo apt install -y build-essential zlib1g-dev \
libncurses5-dev libgdbm-dev libnss3-dev openssl libssl-dev \
libreadline-dev libffi-dev wget libsqlite3-dev
# Grab the python3.10 source
wget https://www.python.org/ftp/python/3.10.10/Python-3.10.10.tgz
tar -xzvf Python-3.10.10.tgz
cd Python-3.10.10
# Takes ~6 minutes on a Pi Zero 1.3 to check what is available
./configure --enable-optimizations
# compiling takes ~80 minutes(!!) on a Pi Zero 1.3
sudo make altinstall
# cleanup
cd ..
sudo rm -rf Python-3.10.10*
# Make python3.10 the default version
sudo update-alternatives --install /usr/bin/python python /usr/local/bin/python3.10 1
sudo update-alternatives --install /usr/bin/python3 python3 /usr/local/bin/python3.10 1
```
sudo apt-get update && sudo apt-get install -y wiringpi python3-pip \
python3-numpy python-pil libopenjp2-7 git python3-opencv \
python3-picamera libatlas-base-dev qrencode
Manually re-install `python3-apt` to avoid error messages in later steps (though, ironically, you will see the "ModuleNotFoundError: No module named 'apt_pkg'" error message during the `apt remove` step):
```bash
sudo apt remove --purge python3-apt -y
sudo apt autoremove -y
sudo apt install python3-apt -y
```
### Install dependencies
Copy this entire box and run it as one command (will take a while to complete):
```bash
sudo apt update && sudo apt install -y wiringpi python3-pip \
python3-numpy python-pil libjpeg-dev zlib1g-dev libopenjp2-7 \
git python3-opencv python3-picamera libatlas-base-dev qrencode
```
### Install `zbar`
@@ -72,17 +112,17 @@ sudo apt-get update && sudo apt-get install -y wiringpi python3-pip \
SeedSigner requires `zbar` at 0.23.x or higher.
Download the binary:
```
```bash
curl -L http://raspbian.raspberrypi.org/raspbian/pool/main/z/zbar/libzbar0_0.23.90-1_armhf.deb --output libzbar0_0.23.90-1_armhf.deb
```
And then install it:
```
```bash
sudo apt install ./libzbar0_0.23.90-1_armhf.deb
```
Cleanup:
```
```bash
rm libzbar0_0.23.90-1_armhf.deb
```
@@ -90,7 +130,7 @@ rm libzbar0_0.23.90-1_armhf.deb
This library "provides functions for reading digital inputs and setting digital outputs, using SPI and I2C, and for accessing the system timers."
Run each of the following individual steps:
```
```bash
wget http://www.airspayce.com/mikem/bcm2835/bcm2835-1.60.tar.gz
tar zxvf bcm2835-1.60.tar.gz
cd bcm2835-1.60/
@@ -102,88 +142,82 @@ sudo rm -rf bcm2835-1.60
```
### Set up `virtualenv`
```
pip3 install virtualenvwrapper
```bash
python -m pip install virtualenvwrapper
```
Edit your bash profile with the command `nano ~/.profile` and add the following to the end:
```
```bash
export WORKON_HOME=$HOME/.envs
export VIRTUALENVWRAPPER_PYTHON=/usr/bin/python3
source /home/pi/.local/bin/virtualenvwrapper.sh
```
Then `CTRL-X` and `y` to exit and save changes.
Now create the python virtualenv for SeedSigner with these two commands:
```
Now create the virtualenv for SeedSigner:
```bash
source ~/.profile
mkvirtualenv --python=python3 seedsigner-env
mkvirtualenv seedsigner-env
```
For convenience you can configure your `.profile` to auto-activate the SeedSigner virtualenv when you ssh in. Once again `nano ~/.profile` and add at the end:
```
```bash
workon seedsigner-env
```
Optional: If you're going to be testing new code on the SeedSigner, you'll find yourself often needing to kill the SeedSigner code that automatically runs at startup (we'll be configuring this further down). As an extra convenience you can list the process id so that you can then kill it from the terminal:
```
ps aux | grep main.py
```
Save your changes with `CTRL-X` and `y`.
Now when you `ssh` in you'll see something like:
```
pi 297 65.4 9.7 74096 36736 ? Rsl 09:26 10:29 /home/pi/.envs/seedsigner-env/bin/python main.py
pi 857 0.0 0.4 7332 1876 pts/0 S+ 09:42 0:00 grep --color=auto main.py
```
The top line is our SeedSigner code running. To stop it, run:
```
kill 297
```
Where `297` is the process id listed in the output above (it'll be different each time).
### Download the SeedSigner code:
```
```bash
git clone https://github.com/SeedSigner/seedsigner
cd seedsigner
```
If you want to run a specific branch within the main SeedSigner repo, switch to it with:
```
git checkout yourtargetbranch
```
And if you want to test a pull request (PR), for example PR #123:
```
git fetch origin pull/123/head:pr_123
git checkout pr_123
```
where `pr_123` is any name you want to give to the new branch in your local repo that will hold the PR.
### Install Python `pip` dependencies:
```
pip3 install -r requirements.txt
```bash
# Takes 1hr 45min on a Pi Zero 1.3
pip install -r requirements.txt
pip install -r requirements-raspi.txt
```
#### `pyzbar`
Note: The `requirements.txt` installs a fork of the python `pyzbar` repo (for now pointing to the fork in Keith's `kdmukai` github account [https://github.com/kdmukai/pyzbar](https://github.com/kdmukai/pyzbar)).
Note: The `requirements.txt` installs a fork of the python `pyzbar` repo.
The fork is required because the main `pyzbar` repo has been abandoned. This [github issue](https://github.com/NaturalHistoryMuseum/pyzbar/issues/124#issuecomment-971967091) discusses the changes needed in order to support reading binary data from `zbar`, which is required for our `CompactSeedQR` format which writes byte data instead of strings. The changes specifically reference the following PRs which have already been merged into Keith's fork:
* [PR 76](https://github.com/NaturalHistoryMuseum/pyzbar/pull/76/files): enables scanning to continue even when a null byte (`x\00`) is found.
* [PR 82](https://github.com/NaturalHistoryMuseum/pyzbar/pull/82): enable `zbar`'s new binary mode. Note that this PR has a trivial bug that was fixed in Keith's fork.
* [PR 82](https://github.com/NaturalHistoryMuseum/pyzbar/pull/82): enable `zbar`'s new binary mode. Note that this PR has a trivial bug that was fixed in our fork.
### Finish configuring the virtualenv
Set the SeedSigner `src/` directory as the project directory for the virtualenv (this is where the virtualenv will take you when you activate it):
```bash
cd src
setvirtualenvproject
```
Test it out:
```bash
# exit the virtualenv
deactivate
# change dirs to somewhere else
cd ~
# activate the virtualenv
workon seedsigner-env
# you should now be back in the SeedSigner src/ directory
pwd
```
### 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:
```
```ini
[Unit]
Description=Seedsigner
@@ -199,46 +233,98 @@ WantedBy=multi-user.target
_Note: For local dev you'll want to edit the `Restart=always` line to `Restart=no`. This way when your dev code crashes it won't keep trying to restart itself. Note that the UI "Reset" will no longer work when auto-restarts are disabled._
_Note: Debugging output is completely wiped via routing the output to `/dev/null 2>&1`. When working in local dev, you're better off disabling the `systemd` SeedSigner service and just directly running the app so you can see all the debugging output live._
_Note: Debugging output is completely wiped via routing the output to `/dev/null 2>&1`. When working in local dev, you'll `kill` the `systemd` SeedSigner service and just directly run the code on demand so you can see all the debugging output live._
Use `CTRL-X` and `y` to exit and save changes.
Configure the service to start running (this will restart the seedsigner code automatically at startup and if it crashes):
```
```bash
sudo systemctl enable seedsigner.service
```
Now reboot the Raspberry Pi:
```
```bash
sudo reboot
```
After the Raspberry Pi reboots, you should see the SeedSigner splash screen and the SeedSigner menu subsequently appear on the LCD screen (note that it can take up to 60 seconds for the menu to appear).
#### Optional: kill `systemd` SeedSigner process on login
If you're going to be testing new code on the device, you'll find yourself often needing to kill the SeedSigner instance that `systemd` automatically runs at startup.
You can configure your `~/.profile` to find and kill the SeedSigner process when you ssh in.
`nano ~/.profile` and add at the end:
```bash
# Find the SeedSigner process and kill it
kill $(ps aux | grep '[m]ain.py' | awk '{print $2}')
```
### Further OS modifications
Disable and remove the system's virtual memory / swap file with the commands:
```
```bash
sudo apt remove dphys-swapfile -y
sudo apt autoremove -y
sudo rm /var/swap
```
## Local testing and development
For those who will use the SeedSigner installation for testing/development, it can be helpful to change the system's host name so it doesn't potentially conflict with other Raspberry Pis that may already be present on your network. (For those who don't plan to use the installation for testing or development, you can skip this portion of the process.) To change the host name first edit the "hostname" with the command:
## Manually start the SeedSigner code
```bash
# activate the virtualenv if you haven't already
workon seedsigner-env
# You should now be in the SeedSigner src/ directory. List its contents:
ls
# You should see the main.py file. Run it:
python main.py
# To kill the process, use CTRL-C
```
## Local testing and development
### Run specific branches or PRs
The default branch is `dev`. If you want to run a specific release tag or a specific branch:
```bash
# release tag for v0.6.0:
git checkout 0.6.0
```
And if you want to test a pull request (PR), for example PR #123:
```bash
git fetch origin pull/123/head:pr_123
git checkout pr_123
```
where `pr_123` is any name you want to give to the new branch in your local repo that will hold the PR.
### Change the host name
For those who will use the SeedSigner installation for testing/development, it can be helpful to change the system's host name so it doesn't potentially conflict with other Raspberry Pis that may already be present on your network. (For those who don't plan to use the installation for testing or development, you can skip this portion of the process.) To change the host name first edit the "hostname" with the command:
```bash
sudo nano /etc/hostname
```
and change "raspberrypi" to "seedsigner" (or another name). Use `CTRL-X` and `y` to exit and save changes. You'll also need to edit the "hosts" file with the command:
```
and change "raspberrypi" to "seedsigner" (or another name). Use `CTRL-X` and `y` to exit and save changes.
You'll also need to edit the "hosts" file with the command:
```bash
sudo nano /etc/hosts
```
and change "raspberrypi" to "seedsigner" (or the other name you previously chose). Use `CTRL-X` and `y` to exit and save changes.
### Set a static IP
Your local machine that `ssh`s into the SeedSigner can sometimes get confused if you're connecting to different SeedSigners that are all identified as `pi@seedsigner.local`. In this case it helps to set a static ip and just `ssh` directly to that instead.
First find your current `nameserver`:
```
```bash
sudo cat /etc/resolv.conf
```
@@ -260,21 +346,26 @@ static domain_name_servers=192.168.1.254
`CTRL-X` and `y` to save changes.
After your next reboot, access this SeedSigner using its new static ip:
```
```bash
# Use the static ip you set above:
ssh pi@192.168.1.200
# But the hostname will still work, too:
ssh pi@seedsigner.local
```
### More convenient `ssh` access:
Power SeedSigner devs will find themselves connecting to a lot of different SeedSigners. This can cause headaches with `ssh`'s built-in protections; a different device that uses the same `ssh` credentials is normally a potential spoofing attack. But we're doing this to ourselves on purpose and so we can carve out exceptions.
On your local machine, run `nano ~/.ssh/config` and add to the end:
```
```conf
host seedsigner.local
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
User pi
LogLevel QUIET
# Set this to the static ip you set above:
host 192.168.1.200
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
@@ -293,7 +384,7 @@ The second entry does the same for a specific static ip; you'll want this if you
You can also configure the SeedSigner so that you don't have to enter the `pi` password when you `ssh` in.
run `ssh-copy-id` with the same values that you connect via `ssh`:
```
```bash
ssh-copy-id pi@seedsigner.local
# or if you're connecting over static ip, something like:
@@ -307,7 +398,7 @@ _Note: If you don't have any ssh keys on your local machine, you'll need to crea
## Disable wifi/Bluetooth when using other Raspi boards
If you plan to use your installation on a Raspberry Pi that is not a Zero version 1.3, but rather on a Raspberry Pi that has WiFi and Bluetooth capabilities, it is a good idea to disable the following WiFi & Bluetooth, as well as other relevant services (assuming you are not creating this installation for testing/development purposes). Enter the followiing commands to disable WiFi, Bluetooth, & other relevant services:
```
```bash
sudo systemctl disable bluetooth.service
sudo systemctl disable wpa_supplicant.service
sudo systemctl disable dhcpcd.service
@@ -316,12 +407,13 @@ sudo systemctl disable networking.service
sudo systemctl disable dphys-swapfile.service
sudo ifconfig wlan0 down
```
Please note that if you are using WiFi to connect/interact with your Raspberry Pi, the last command will sever that connection.
You can now safely power the Raspberry Pi off from the SeedSigner main menu.
If you do not plan to use your installation for testing/development, it is also a good idea to disable WiFi and Bluetooth by editing the config.txt file found in the installation's "boot" partition. You can add the following text to the end of that file with any simple text editor (Windows: Notepad, Mac: TextEdit, Linux: nano):
```
```ini
dtoverlay=disable-bt
dtoverlay=pi3-disable-wifi
```
+3
View File
@@ -0,0 +1,3 @@
picamera==1.13
RPi.GPIO==0.7.0
spidev==3.5
-3
View File
@@ -1,10 +1,7 @@
embit==0.7.0
numpy==1.21.1
picamera==1.13
Pillow==8.2.0
-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
+46 -1
View File
@@ -32,6 +32,23 @@ 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 Controller(Singleton):
"""
The Controller is a globally available singleton that maintains SeedSigner state.
@@ -185,7 +202,13 @@ class Controller(Singleton):
self.back_stack = BackStack()
def start(self) -> None:
def start(self, initial_destination: Destination = None) -> None:
"""
The main loop of the application.
* initial_destination: The first View to run. If None, the MainMenuView is
used. Only used by the test suite.
"""
from .views import MainMenuView, BackStackView
from .views.screensaver import OpeningSplashScreen
@@ -218,6 +241,9 @@ class Controller(Singleton):
View_cls(**init_args).run()
"""
try:
if initial_destination:
next_destination = initial_destination
else:
next_destination = Destination(MainMenuView)
while True:
# Destination(None) is a special case; render the Home screen
@@ -240,10 +266,24 @@ 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:
@@ -309,7 +349,12 @@ class Controller(Singleton):
# So we extract the Exception type and trim off any "foo.bar." namespacing:
last_line = traceback.format_exc().splitlines()[-1]
exception_type = last_line.split(":")[0].split(".")[-1]
# Extract the error message, if there is one
if ":" in last_line:
exception_msg = last_line.split(":")[1]
else:
exception_msg = ""
# Scan for the last debugging line that includes a line number reference
line_info = None
+2 -1
View File
@@ -379,7 +379,8 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen):
self.keyboard.render_keys()
elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN:
elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN \
or input in (Keyboard.ENTER_TOP, Keyboard.ENTER_BOTTOM):
if ret_val in self.possible_alphabet:
# Live joystick movement; haven't locked this new letter in yet.
# Replace the last letter w/the currently selected one. But don't
+1 -1
View File
@@ -153,7 +153,7 @@ class ST7789(object):
if imwidth != self.width or imheight != self.height:
raise ValueError('Image must be same dimensions as display \
({0}x{1}).' .format(self.width, self.height))
# 24-bit RGB-8:8:8 becomes 16-bit BGR-5:6:5 plus endianness toggle.
# convert 24-bit RGB-8:8:8 to gBRG-3:5:5:3; then per-pixel byteswap to 16-bit RGB-5:6:5
arr = array.array("H", Image.convert("BGR;16").tobytes())
arr.byteswap()
pix = arr.tobytes()
+2 -2
View File
@@ -218,7 +218,7 @@ class SettingsEntry:
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):
@@ -440,7 +440,7 @@ class SettingsDefinition:
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,
+72 -59
View File
@@ -11,9 +11,8 @@ 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 seedsigner.gui.screens.psbt_screens import PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen
from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, DireWarningScreen, QRDisplayScreen)
from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
@@ -22,6 +21,11 @@ logger = logging.getLogger(__name__)
class PSBTSelectSeedView(View):
SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE)
TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
button_data = []
def 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.
@@ -31,32 +35,29 @@ class PSBTSelectSeedView(View):
seeds = self.controller.storage.seeds
SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE)
TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
button_data = []
for seed in seeds:
button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
if not PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
# Doesn't look like this seed can sign the current PSBT
button_str += " (?)"
button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue"))
self.button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue"))
button_data.append(SCAN_SEED)
button_data.append(TYPE_12WORD)
button_data.append(TYPE_24WORD)
self.button_data.append(self.SCAN_SEED)
self.button_data.append(self.TYPE_12WORD)
self.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()
button_data=self.button_data
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -69,13 +70,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:
if self.button_data[selected_menu_num] == self.SCAN_SEED:
from seedsigner.views.scan_views import ScanView
return Destination(ScanView)
elif button_data[selected_menu_num] in [TYPE_12WORD, TYPE_24WORD]:
elif 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)
@@ -92,8 +93,10 @@ class PSBTOverviewView(View):
if not self.controller.psbt_parser or self.controller.psbt_parser.seed != self.controller.psbt_seed:
# The PSBTParser takes a while to read the PSBT. Run the loading screen while
# we wait.
from seedsigner.gui.screens.screen import LoadingScreenThread
self.loading_screen = LoadingScreenThread(text="Parsing PSBT...")
self.loading_screen.start()
try:
self.controller.psbt_parser = PSBTParser(
self.controller.psbt,
@@ -128,23 +131,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)
@@ -214,14 +216,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)
@@ -238,7 +241,10 @@ class PSBTAddressDetailsView(View):
"""
Shows the recipient's address and amount they will receive
"""
def __init__(self, address_num, is_change=False):
NEXT = "Next"
button_data = []
def __init__(self, address_num):
super().__init__()
self.address_num = address_num
@@ -254,23 +260,27 @@ class PSBTAddressDetailsView(View):
if psbt_parser.num_destinations > 1:
title += f" (#{self.address_num + 1})"
button_data = []
if self.address_num < psbt_parser.num_destinations - 1:
button_data.append("Next Recipient")
self.NEXT = "Next Recipient"
else:
button_data.append("Next")
self.NEXT = "Next"
self.button_data.append(self.NEXT)
selected_menu_num = psbt_screens.PSBTAddressDetailsScreen(
selected_menu_num = self.run_screen(
PSBTAddressDetailsScreen,
title=title,
button_data=button_data,
button_data=self.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 selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if self.button_data[selected_menu_num] == self.NEXT:
if self.address_num < len(psbt_parser.destination_addresses) - 1:
# Show the next receive addr
return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1, "is_change": False})
return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1})
elif psbt_parser.change_amount > 0:
# Move on to display change
@@ -280,14 +290,13 @@ class PSBTAddressDetailsView(View):
# 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)
class PSBTChangeDetailsView(View):
"""
"""
NEXT = "Next"
VERIFY_MULTISIG = "Verify Multisig Change"
button_data = [NEXT]
def __init__(self, change_address_num):
super().__init__()
self.change_address_num = change_address_num
@@ -327,14 +336,12 @@ class PSBTChangeDetailsView(View):
is_change_derivation_path = int(derivation_path.split("/")[-2]) == 1
derivation_path_addr_index = int(derivation_path.split("/")[-1])
NEXT = "Next"
if is_change_derivation_path:
title = "Your Change"
VERIFY_MULTISIG = "Verify Multisig Change"
self.VERIFY_MULTISIG = "Verify Multisig Change"
else:
title = "Self-Transfer"
VERIFY_MULTISIG = "Verify Multisig Addr"
self.VERIFY_MULTISIG = "Verify Multisig Addr"
# if psbt_parser.num_change_outputs > 1:
# title += f" (#{self.change_address_num + 1})"
@@ -343,11 +350,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]
self.button_data = [self.NEXT]
else:
# Have the Screen offer to load in the multisig descriptor.
button_data = [VERIFY_MULTISIG, NEXT]
self.button_data = [self.VERIFY_MULTISIG, self.NEXT]
else:
# Single sig
@@ -356,6 +363,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()
@@ -389,7 +397,7 @@ class PSBTChangeDetailsView(View):
if change_data["address"] == calc_address:
is_change_addr_verified = True
button_data = [NEXT]
self.button_data = [self.NEXT]
finally:
loading_screen.stop()
@@ -397,9 +405,10 @@ 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,
button_data=self.button_data,
address=change_data.get("address"),
amount=change_data.get("amount"),
is_multisig=psbt_parser.is_multisig,
@@ -408,19 +417,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 self.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 self.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)
@@ -459,6 +468,9 @@ class PSBTAddressVerificationFailedView(View):
class PSBTFinalizeView(View):
"""
"""
APPROVE_PSBT = "Approve PSBT"
button_data = [APPROVE_PSBT]
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
psbt: PSBT = self.controller.psbt
@@ -467,11 +479,12 @@ class PSBTFinalizeView(View):
# Should not be able to get here
return Destination(MainMenuView)
selected_menu_num = psbt_screens.PSBTFinalizeScreen(
button_data=["Approve PSBT"]
).display()
selected_menu_num = self.run_screen(
PSBTFinalizeScreen,
button_data=self.button_data
)
if selected_menu_num == 0:
if self.button_data[selected_menu_num] == self.APPROVE_PSBT:
# Sign PSBT
sig_cnt = PSBTParser.sig_count(psbt)
psbt.sign_with(psbt_parser.root)
@@ -500,7 +513,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).
+13 -7
View File
@@ -4,6 +4,7 @@ import re
from embit.descriptor import Descriptor
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
from seedsigner.gui.screens import scan_screens
from seedsigner.models import DecodeQR, Seed
from seedsigner.models.settings import SettingsConstants
@@ -12,19 +13,24 @@ from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Dest
class ScanView(View):
def __init__(self):
super().__init__()
# Set up the QR decoder here 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(wordlist_language_code=self.wordlist_language_code)
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(scan_screens.ScanScreen, decoder=self.decoder)
# Handle the results
if self.decoder.is_complete:
if self.decoder.is_seed:
seed_mnemonic = self.decoder.get_seed_phrase()
if not seed_mnemonic:
# seed is not valid, Exit if not valid with message
raise Exception("Not yet implemented!")
@@ -33,7 +39,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
+184 -128
View File
@@ -13,7 +13,7 @@ from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustom
from seedsigner.helpers import embit_utils
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen,
WarningScreen, DireWarningScreen, seed_screens)
from seedsigner.gui.screens.screen import LargeIconStatusScreen, LoadingScreenThread, QRDisplayScreen
from seedsigner.gui.screens.screen import LargeIconStatusScreen, QRDisplayScreen
from seedsigner.models.decode_qr import DecodeQR
from seedsigner.models.encode_qr import EncodeQR
from seedsigner.models.psbt_parser import PSBTParser
@@ -31,6 +31,8 @@ from .view import NotYetImplementedView, View, Destination, BackStackView, MainM
class SeedsMenuView(View):
LOAD = "Load a seed"
def __init__(self):
super().__init__()
self.seeds = []
@@ -50,11 +52,12 @@ class SeedsMenuView(View):
button_data.append((seed["fingerprint"], SeedSignerCustomIconConstants.FINGERPRINT, "blue"))
button_data.append("Load a seed")
selected_menu_num = ButtonListScreen(
selected_menu_num = self.run_screen(
ButtonListScreen,
title="In-Memory Seeds",
is_button_text_centered=False,
button_data=button_data
).display()
)
if len(self.seeds) > 0 and selected_menu_num < len(self.seeds):
return Destination(SeedOptionsView, view_args={"seed_num": selected_menu_num})
@@ -71,40 +74,41 @@ class SeedsMenuView(View):
Loading seeds, passphrases, etc
****************************************************************************"""
class LoadSeedView(View):
def run(self):
SEED_QR = (" Scan a SeedQR", FontAwesomeIconConstants.QRCODE)
TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
CREATE = (" Create a seed", FontAwesomeIconConstants.PLUS)
button_data=[
SEED_QR,
TYPE_12WORD,
TYPE_24WORD,
CREATE,
]
selected_menu_num = ButtonListScreen(
def run(self):
button_data = [
self.SEED_QR,
self.TYPE_12WORD,
self.TYPE_24WORD,
self.CREATE,
]
selected_menu_num = self.run_screen(
ButtonListScreen,
title="Load A Seed",
is_button_text_centered=False,
button_data=button_data
).display()
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if button_data[selected_menu_num] == SEED_QR:
if button_data[selected_menu_num] == self.SEED_QR:
from .scan_views import ScanView
return Destination(ScanView)
elif button_data[selected_menu_num] == TYPE_12WORD:
elif button_data[selected_menu_num] == self.TYPE_12WORD:
self.controller.storage.init_pending_mnemonic(num_words=12)
return Destination(SeedMnemonicEntryView)
elif button_data[selected_menu_num] == TYPE_24WORD:
elif button_data[selected_menu_num] == self.TYPE_24WORD:
self.controller.storage.init_pending_mnemonic(num_words=24)
return Destination(SeedMnemonicEntryView)
elif button_data[selected_menu_num] == CREATE:
elif button_data[selected_menu_num] == self.CREATE:
from .tools_views import ToolsMenuView
return Destination(ToolsMenuView)
@@ -119,22 +123,16 @@ class SeedMnemonicEntryView(View):
def run(self):
ret = seed_screens.SeedMnemonicEntryScreen(
ret = self.run_screen(
seed_screens.SeedMnemonicEntryScreen,
title=f"Seed Word #{self.cur_word_index + 1}", # Human-readable 1-indexing!
initial_letters=list(self.cur_word) if self.cur_word else ["a"],
wordlist=Seed.get_wordlist(wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)),
).display()
)
if ret == RET_CODE__BACK_BUTTON:
if self.cur_word_index > 0:
return Destination(BackStackView)
# return Destination(
# SeedMnemonicEntryView,
# view_args={
# "cur_word_index": self.cur_word_index - 1,
# "is_calc_final_word": self.is_calc_final_word
# }
# )
else:
self.controller.storage.discard_pending_mnemonic()
return Destination(MainMenuView)
@@ -174,34 +172,38 @@ class SeedMnemonicEntryView(View):
class SeedMnemonicInvalidView(View):
EDIT = "Review & Edit"
DISCARD = ("Discard", None, None, "red")
def __init__(self):
super().__init__()
self.mnemonic: List[str] = self.controller.storage.pending_mnemonic
def run(self):
EDIT = "Review & Edit"
DISCARD = ("Discard", None, None, "red")
button_data = [EDIT, DISCARD]
selected_menu_num = WarningScreen(
button_data = [self.EDIT, self.DISCARD]
selected_menu_num = self.run_screen(
WarningScreen,
title="Invalid Mnemonic!",
status_headline=None,
text=f"Checksum failure; not a valid seed phrase.",
show_back_button=False,
button_data=button_data,
).display()
)
if button_data[selected_menu_num] == EDIT:
if button_data[selected_menu_num] == self.EDIT:
return Destination(SeedMnemonicEntryView, view_args={"cur_word_index": 0})
elif button_data[selected_menu_num] == DISCARD:
elif button_data[selected_menu_num] == self.DISCARD:
self.controller.storage.discard_pending_mnemonic()
return Destination(MainMenuView)
class SeedFinalizeView(View):
FINALIZE = "Done"
PASSPHRASE = "BIP-39 Passphrase"
def __init__(self):
super().__init__()
self.seed = self.controller.storage.get_pending_seed()
@@ -209,25 +211,21 @@ class SeedFinalizeView(View):
def run(self):
FINALIZE = "Done"
PASSPHRASE = "BIP-39 Passphrase"
button_data = []
button_data.append(FINALIZE)
button_data = [self.FINALIZE]
if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED:
button_data.append(PASSPHRASE)
button_data.append(self.PASSPHRASE)
selected_menu_num = seed_screens.SeedFinalizeScreen(
selected_menu_num = self.run_screen(
seed_screens.SeedFinalizeScreen,
fingerprint=self.fingerprint,
button_data=button_data,
).display()
)
if button_data[selected_menu_num] == FINALIZE:
if button_data[selected_menu_num] == self.FINALIZE:
seed_num = self.controller.storage.finalize_pending_seed()
return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True)
elif button_data[selected_menu_num] == PASSPHRASE:
elif button_data[selected_menu_num] == self.PASSPHRASE:
return Destination(SeedAddPassphraseView)
@@ -239,7 +237,7 @@ class SeedAddPassphraseView(View):
def run(self):
ret = seed_screens.SeedAddPassphraseScreen(passphrase=self.seed.passphrase).display()
ret = self.run_screen(seed_screens.SeedAddPassphraseScreen, passphrase=self.seed.passphrase)
if ret == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -257,16 +255,15 @@ class SeedReviewPassphraseView(View):
"""
Display the completed passphrase back to the user.
"""
EDIT = "Edit passphrase"
DONE = "Done"
def __init__(self):
super().__init__()
self.seed = self.controller.storage.get_pending_seed()
def run(self):
EDIT = "Edit passphrase"
DONE = "Done"
button_data = [EDIT, DONE]
# Get the before/after fingerprints
network = self.settings.get_value(SettingsConstants.SETTING__NETWORK)
passphrase = self.seed.passphrase
@@ -275,26 +272,32 @@ class SeedReviewPassphraseView(View):
fingerprint_without = self.seed.get_fingerprint(network=network)
self.seed.set_passphrase(passphrase)
# Because we have ane explicit "Edit" button, we disable "BACK" to keep the
button_data = [self.EDIT, self.DONE]
# Because we have an explicit "Edit" button, we disable "BACK" to keep the
# routing options sane.
selected_menu_num = seed_screens.SeedReviewPassphraseScreen(
selected_menu_num = self.run_screen(
seed_screens.SeedReviewPassphraseScreen,
fingerprint_without=fingerprint_without,
fingerprint_with=fingerprint_with,
passphrase=self.seed.passphrase,
button_data=button_data,
show_back_button=False,
).display()
)
if button_data[selected_menu_num] == EDIT:
if button_data[selected_menu_num] == self.EDIT:
return Destination(SeedAddPassphraseView)
elif button_data[selected_menu_num] == DONE:
elif button_data[selected_menu_num] == self.DONE:
seed_num = self.controller.storage.finalize_pending_seed()
return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True)
class SeedDiscardView(View):
KEEP = "Keep Seed"
DISCARD = ("Discard", None, None, "red")
def __init__(self, seed_num: int = None):
super().__init__()
self.seed_num = seed_num
@@ -305,27 +308,26 @@ class SeedDiscardView(View):
def run(self):
KEEP = "Keep Seed"
DISCARD = ("Discard", None, None, "red")
button_data = [KEEP, DISCARD]
button_data = [self.KEEP, self.DISCARD]
fingerprint = self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
selected_menu_num = WarningScreen(
selected_menu_num = self.run_screen(
WarningScreen,
title="Discard Seed?",
status_headline=None,
text=f"Wipe seed {fingerprint} from the device?",
show_back_button=False,
button_data=button_data,
).display()
)
if button_data[selected_menu_num] == KEEP:
if button_data[selected_menu_num] == self.KEEP:
# Use skip_current_view=True to prevent BACK from landing on this warning screen
if self.seed_num is not None:
return Destination(SeedOptionsView, view_args={"seed_num": self.seed_num}, skip_current_view=True)
else:
return Destination(SeedFinalizeView, skip_current_view=True)
elif button_data[selected_menu_num] == DISCARD:
elif button_data[selected_menu_num] == self.DISCARD:
if self.seed_num is not None:
self.controller.discard_seed(self.seed_num)
else:
@@ -338,6 +340,15 @@ class SeedDiscardView(View):
Views for actions on individual seeds:
****************************************************************************"""
class SeedOptionsView(View):
SCAN_PSBT = ("Scan PSBT", FontAwesomeIconConstants.QRCODE)
VERIFY_ADDRESS = "Verify Addr"
EXPORT_XPUB = "Export Xpub"
EXPLORER = "Address Explorer"
BACKUP = ("Backup Seed", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)
BIP85_CHILD_SEED = "BIP-85 Child Seed"
DISCARD = ("Discard Seed", None, None, "red")
def __init__(self, seed_num: int):
super().__init__()
self.seed_num = seed_num
@@ -347,31 +358,17 @@ class SeedOptionsView(View):
def run(self):
from seedsigner.views.psbt_views import PSBTOverviewView
SCAN_PSBT = ("Scan PSBT", FontAwesomeIconConstants.QRCODE)
VERIFY_ADDRESS = "Verify Addr"
EXPORT_XPUB = "Export Xpub"
EXPLORER = "Address Explorer"
BACKUP = ("Backup Seed", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)
BIP85_CHILD_SEED = "BIP-85 Child Seed"
DISCARD = ("Discard Seed", None, None, "red")
button_data = []
if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER:
# Jump straight back into the address explorer script type selection flow
# But do ont cancel the `resume_main_flow` as we'll still need that after
# derivation path is specified.
return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG), skip_current_view=True)
if self.controller.unverified_address:
if self.controller.resume_main_flow == Controller.FLOW__VERIFY_SINGLESIG_ADDR:
# Jump straight back into the single sig addr verification flow
self.controller.resume_main_flow = None
return Destination(SeedAddressVerificationView, view_args=dict(seed_num=self.seed_num), skip_current_view=True)
addr = self.controller.unverified_address["address"][:7]
VERIFY_ADDRESS += f" {addr}"
button_data.append(VERIFY_ADDRESS)
if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER:
# Jump straight back into the address explorer script type selection flow
# But don't cancel the `resume_main_flow` as we'll still need that after
# derivation path is specified.
return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG), skip_current_view=True)
if self.controller.psbt:
if PSBTParser.has_matching_input_fingerprint(self.controller.psbt, self.seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
@@ -381,56 +378,67 @@ class SeedOptionsView(View):
self.controller.psbt_seed = self.seed
return Destination(PSBTOverviewView, skip_current_view=True)
button_data.append(SCAN_PSBT)
button_data = []
if self.controller.unverified_address:
addr = self.controller.unverified_address["address"][:7]
self.VERIFY_ADDRESS += f" {addr}"
button_data.append(self.VERIFY_ADDRESS)
button_data.append(self.SCAN_PSBT)
if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED:
button_data.append(EXPORT_XPUB)
button_data.append(self.EXPORT_XPUB)
button_data.append(EXPLORER)
button_data.append(BACKUP)
button_data.append(self.EXPLORER)
button_data.append(self.BACKUP)
if self.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__ENABLED:
button_data.append(BIP85_CHILD_SEED)
button_data.append(self.BIP85_CHILD_SEED)
button_data.append(DISCARD)
button_data.append(self.DISCARD)
selected_menu_num = seed_screens.SeedOptionsScreen(
selected_menu_num = self.run_screen(
seed_screens.SeedOptionsScreen,
button_data=button_data,
fingerprint=self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)),
has_passphrase=self.seed.passphrase is not None,
).display()
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
# Force BACK to always return to the Main Menu
return Destination(MainMenuView)
if button_data[selected_menu_num] == SCAN_PSBT:
if button_data[selected_menu_num] == self.SCAN_PSBT:
from seedsigner.views.scan_views import ScanView
self.controller.psbt_seed = self.controller.get_seed(self.seed_num)
return Destination(ScanView)
elif button_data[selected_menu_num] == VERIFY_ADDRESS:
elif button_data[selected_menu_num] == self.VERIFY_ADDRESS:
return Destination(SeedAddressVerificationView, view_args=dict(seed_num=self.seed_num))
elif button_data[selected_menu_num] == EXPORT_XPUB:
elif button_data[selected_menu_num] == self.EXPORT_XPUB:
return Destination(SeedExportXpubSigTypeView, view_args=dict(seed_num=self.seed_num))
elif button_data[selected_menu_num] == EXPLORER:
elif button_data[selected_menu_num] == self.EXPLORER:
self.controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER
return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG))
elif button_data[selected_menu_num] == BACKUP:
elif button_data[selected_menu_num] == self.BACKUP:
return Destination(SeedBackupView, view_args=dict(seed_num=self.seed_num))
elif button_data[selected_menu_num] == BIP85_CHILD_SEED:
elif button_data[selected_menu_num] == self.BIP85_CHILD_SEED:
return Destination(SeedBIP85ApplicationModeView, view_args={"seed_num": self.seed_num})
elif button_data[selected_menu_num] == DISCARD:
elif button_data[selected_menu_num] == self.DISCARD:
return Destination(SeedDiscardView, view_args=dict(seed_num=self.seed_num))
class SeedBackupView(View):
VIEW_WORDS = "View Seed Words"
EXPORT_SEEDQR = "Export as SeedQR"
def __init__(self, seed_num):
super().__init__()
self.seed_num = seed_num
@@ -438,23 +446,22 @@ class SeedBackupView(View):
def run(self):
VIEW_WORDS = "View Seed Words"
EXPORT_SEEDQR = "Export as SeedQR"
button_data = [VIEW_WORDS, EXPORT_SEEDQR]
button_data = [self.VIEW_WORDS, self.EXPORT_SEEDQR]
selected_menu_num = ButtonListScreen(
selected_menu_num = self.run_screen(
ButtonListScreen,
title="Backup Seed",
button_data=button_data,
is_bottom_list=True,
).display()
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
elif button_data[selected_menu_num] == VIEW_WORDS:
elif button_data[selected_menu_num] == self.VIEW_WORDS:
return Destination(SeedWordsWarningView, view_args={"seed_num": self.seed_num})
elif button_data[selected_menu_num] == EXPORT_SEEDQR:
elif button_data[selected_menu_num] == self.EXPORT_SEEDQR:
return Destination(SeedTranscribeSeedQRFormatView, view_args={"seed_num": self.seed_num})
@@ -463,6 +470,9 @@ class SeedBackupView(View):
Export Xpub flow
****************************************************************************"""
class SeedExportXpubSigTypeView(View):
SINGLE_SIG = "Single Sig"
MULTISIG = "Multisig"
def __init__(self, seed_num: int):
super().__init__()
self.seed_num = seed_num
@@ -473,22 +483,21 @@ class SeedExportXpubSigTypeView(View):
# Nothing to select; skip this screen
return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": self.settings.get_value(SettingsConstants.SETTING__SIG_TYPES)[0]}, skip_current_view=True)
SINGLE_SIG = "Single Sig"
MULTISIG = "Multisig"
button_data=[SINGLE_SIG, MULTISIG]
button_data=[self.SINGLE_SIG, self.MULTISIG]
selected_menu_num = ButtonListScreen(
selected_menu_num = self.run_screen(
ButtonListScreen,
title="Export Xpub",
button_data=button_data
).display()
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if button_data[selected_menu_num] == SINGLE_SIG:
if button_data[selected_menu_num] == self.SINGLE_SIG:
return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.SINGLE_SIG})
elif button_data[selected_menu_num] == MULTISIG:
elif button_data[selected_menu_num] == self.MULTISIG:
return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.MULTISIG})
@@ -513,20 +522,21 @@ class SeedExportXpubScriptTypeView(View):
else:
return Destination(SeedExportXpubCoordinatorView, view_args=args, skip_current_view=True)
button_data = []
for script_type in self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES):
button_data.append(script_type)
title = "Export Xpub"
if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER:
title = "Address Explorer"
selected_menu_num = ButtonListScreen(
button_data = []
for script_type in self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES):
button_data.append(script_type)
selected_menu_num = self.run_screen(
ButtonListScreen,
title=title,
is_button_text_centered=False,
button_data=button_data,
is_bottom_list=True,
).display()
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
# If previous view is SeedOptionsView then that should be where resume_main_flow started (otherwise it would have been skipped).
@@ -558,10 +568,12 @@ class SeedExportXpubCustomDerivationView(View):
self.script_type = script_type
self.custom_derivation_path = "m/"
def run(self):
ret = seed_screens.SeedExportXpubCustomDerivationScreen(
ret = self.run_screen(
seed_screens.SeedExportXpubCustomDerivationScreen,
initial_value=self.custom_derivation_path,
).display()
)
if ret == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -606,11 +618,12 @@ class SeedExportXpubCoordinatorView(View):
args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[0]
return Destination(SeedExportXpubWarningView, view_args=args, skip_current_view=True)
selected_menu_num = ButtonListScreen(
selected_menu_num = self.run_screen(
ButtonListScreen,
title="Export Xpub",
is_button_text_centered=False,
button_data=self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS),
).display()
)
if selected_menu_num < len(self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)):
args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[selected_menu_num]
@@ -648,10 +661,11 @@ class SeedExportXpubWarningView(View):
# Skip the WarningView entirely
return destination
selected_menu_num = WarningScreen(
selected_menu_num = self.run_screen(
WarningScreen,
status_headline="Privacy Leak!",
text="""Xpub can be used to view all future transactions.""",
).display()
)
if selected_menu_num == 0:
# User clicked "I Understand"
@@ -694,6 +708,7 @@ class SeedExportXpubDetailsView(View):
else:
# The derivation calc takes a few moments. Run the loading screen while we wait.
from seedsigner.gui.screens.screen import LoadingScreenThread
self.loading_screen = LoadingScreenThread(text="Generating xpub...")
self.loading_screen.start()
@@ -716,12 +731,13 @@ class SeedExportXpubDetailsView(View):
finally:
self.loading_screen.stop()
selected_menu_num = seed_screens.SeedExportXpubDetailsScreen(
selected_menu_num = self.run_screen(
seed_screens.SeedExportXpubDetailsScreen,
fingerprint=fingerprint,
has_passphrase=self.seed.passphrase is not None,
derivation_path=derivation_path,
xpub=xpub_base58,
).display()
)
if selected_menu_num == 0:
return Destination(
@@ -773,7 +789,10 @@ class SeedExportXpubQRDisplayView(View):
def run(self):
QRDisplayScreen(qr_encoder=self.qr_encoder).display()
self.run_screen(
QRDisplayScreen,
qr_encoder=self.qr_encoder
)
return Destination(MainMenuView)
@@ -803,9 +822,10 @@ class SeedWordsWarningView(View):
# Forward straight to showing the words
return destination
selected_menu_num = DireWarningScreen(
text="""You must keep your seed words private & away from all online devices.""",
).display()
selected_menu_num = self.run_screen(
DireWarningScreen,
text="""Never input your seed phrase into a device that connects to the internet.""",
)
if selected_menu_num == 0:
# User clicked "I Understand"
@@ -942,6 +962,16 @@ class SeedBIP85SelectChildIndexView(View):
if ret == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if not 0 <= int(ret) < 2**31:
return Destination(
SeedBIP85InvalidChildIndexView,
view_args=dict(
seed_num=self.seed_num,
num_words=self.num_words
),
skip_current_view=True
)
return Destination(
SeedWordsWarningView,
view_args=dict(
@@ -951,6 +981,32 @@ class SeedBIP85SelectChildIndexView(View):
)
class SeedBIP85InvalidChildIndexView(View):
def __init__(self, seed_num: int, num_words: int):
super().__init__()
self.seed_num = seed_num
self.num_words = num_words
def run(self):
DireWarningScreen(
title="BIP-85 Index Error",
show_back_button=False,
status_headline=f"Invalid Child Index",
text=f"BIP-85 Child Index must be between 0 and {2**31-1}.",
button_data=["Try Again"]
).display()
return Destination(
SeedBIP85SelectChildIndexView,
view_args=dict(
seed_num=self.seed_num,
num_words=self.num_words
),
skip_current_view=True
)
"""****************************************************************************
Seed Words Backup Test
@@ -1206,7 +1262,7 @@ class SeedTranscribeSeedQRWarningView(View):
selected_menu_num = DireWarningScreen(
status_headline="SeedQR is your private key!",
text="""Never photograph it or scan it into an online device.""",
text="""Never photograph or scan it into a device that connects to the internet.""",
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
+29 -26
View File
@@ -1,7 +1,6 @@
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants
from seedsigner.models.decode_qr import DecodeQR
from seedsigner.gui.components import SeedSignerCustomIconConstants
from .view import View, Destination, BackStackView, MainMenuView
from .view import View, Destination, MainMenuView
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens)
from seedsigner.models.settings import SettingsConstants, SettingsDefinition
@@ -9,6 +8,9 @@ from seedsigner.models.settings import SettingsConstants, SettingsDefinition
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,9 +21,6 @@ class SettingsMenuView(View):
def run(self):
IO_TEST = "I/O test"
DONATE = "Donate"
settings_entries = SettingsDefinition.get_settings_entries(
visibility=self.visibility
)
@@ -39,34 +38,34 @@ class SettingsMenuView(View):
# 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})
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
# 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 +76,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 +94,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
@@ -111,7 +110,9 @@ class SettingsEntryUpdateSelectionView(View):
value, display_name = value
else:
display_name = value
button_data.append(display_name)
if (type(initial_value) == list and value in initial_value) or value == initial_value:
checked_buttons.append(i)
@@ -120,17 +121,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 +180,8 @@ 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)
@@ -187,7 +190,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 +198,6 @@ class IOTestView(View):
class DonateView(View):
def run(self):
settings_screens.DonateScreen().display()
self.run_screen(settings_screens.DonateScreen)
return Destination(SettingsMenuView)
+56 -48
View File
@@ -7,8 +7,6 @@ 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.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen)
@@ -25,32 +23,34 @@ from .view import View, Destination, BackStackView
class ToolsMenuView(View):
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(
def run(self):
button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.EXPLORER]
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)
@@ -232,28 +232,30 @@ class ToolsDiceEntropyEntryView(View):
Calc final word Views
****************************************************************************"""
class ToolsCalcFinalWordNumWordsView(View):
def run(self):
TWELVE = "12 words"
TWENTY_FOUR = "24 words"
button_data = [TWELVE, TWENTY_FOUR]
selected_menu_num = ButtonListScreen(
def run(self):
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))
@@ -423,29 +425,27 @@ 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 = []
def run(self):
seeds = self.controller.storage.seeds
button_data = []
for seed in seeds:
button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue"))
button_data = button_data + [self.SCAN_SEED, self.SCAN_DESCRIPTOR, self.TYPE_12WORD, self.TYPE_24WORD]
button_data.append(SCAN_SEED)
button_data.append(SCAN_DESCRIPTOR)
button_data.append(TYPE_12WORD)
button_data.append(TYPE_24WORD)
selected_menu_num = ButtonListScreen(
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)
@@ -465,13 +465,13 @@ class ToolsAddressExplorerSelectSourceView(View):
)
)
elif button_data[selected_menu_num] in [SCAN_SEED, SCAN_DESCRIPTOR]:
elif button_data[selected_menu_num] in [self.SCAN_SEED, self.SCAN_DESCRIPTOR]:
from seedsigner.views.scan_views import ScanView
return Destination(ScanView)
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)
@@ -480,6 +480,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
@@ -528,29 +532,28 @@ class ToolsAddressExplorerAddressTypeView(View):
def run(self):
data = self.controller.address_explorer_data
RECEIVE = "Receive Addresses"
CHANGE = "Change Addresses"
button_data = [RECEIVE, CHANGE]
wallet_descriptor_display_name = None
if "wallet_descriptor" in data:
wallet_descriptor_display_name = data["wallet_descriptor"].brief_policy.replace(" (sorted)", "")
script_type = data["script_type"] if "script_type" in data else None
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:
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))
@@ -565,7 +568,7 @@ class ToolsAddressExplorerAddressListView(View):
def run(self):
self.loading_screen = None
try:
addresses = []
button_data = []
data = self.controller.address_explorer_data
@@ -576,10 +579,12 @@ class ToolsAddressExplorerAddressListView(View):
addr_storage_key = "change_addrs"
if addr_storage_key in data and len(data[addr_storage_key]) >= self.start_index + addrs_per_screen:
# We already calculated this range addresses; just retrieve them
# We already calculated this range of addresses; just retrieve them
addresses = data[addr_storage_key][self.start_index:self.start_index + addrs_per_screen]
else:
try:
from seedsigner.gui.screens.screen import LoadingScreenThread
self.loading_screen = LoadingScreenThread(text="Calculating addrs...")
self.loading_screen.start()
@@ -608,9 +613,15 @@ class ToolsAddressExplorerAddressListView(View):
else:
raise Exception("Single sig descriptors not yet supported")
finally:
# Everything is set. Stop the loading screen
self.loading_screen.stop()
for i, address in enumerate(addresses):
cur_index = i + self.start_index
# Adjust the trailing addr display length based on available room
# (the index number will push it out on each order of magnitude)
if cur_index < 10:
end_digits = -6
elif cur_index < 100:
@@ -621,7 +632,8 @@ class ToolsAddressExplorerAddressListView(View):
button_data.append(("Next {}".format(addrs_per_screen), None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT))
screen = ButtonListScreen(
selected_menu_num = self.run_screen(
ButtonListScreen,
title="{} Addrs".format("Receive" if not self.is_change else "Change"),
button_data=button_data,
button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
@@ -631,12 +643,6 @@ class ToolsAddressExplorerAddressListView(View):
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:
self.loading_screen.stop()
selected_menu_num = screen.display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -646,7 +652,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)
@@ -664,10 +670,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)
+71 -43
View File
@@ -1,9 +1,9 @@
from dataclasses import dataclass
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
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.threads import BaseThread
from seedsigner.models import Settings
@@ -56,13 +56,19 @@ class View:
self.canvas_width = self.renderer.canvas_width
self.canvas_height = self.renderer.canvas_height
self.buttons = self.controller.buttons
self.screen = None
def run(self, **kwargs):
if hasattr(self, "screen"):
self.screen.display()
else:
def run_screen(self, Screen_cls: Type[BaseScreen], **kwargs) -> int | str:
"""
Instantiates the provided Screen_cls and runs its interactive display.
Returns the user's input upon completion.
"""
self.screen = Screen_cls(**kwargs)
return self.screen.display()
def run(self, **kwargs) -> 'Destination':
raise Exception("Must implement in the child class")
@@ -73,7 +79,7 @@ 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_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"
@@ -93,12 +99,22 @@ 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):
return self.view.run()
def run(self):
self._instantiate_view()
return self._run_view()
def __eq__(self, obj):
@@ -121,53 +137,63 @@ 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", FontAwesomeIconConstants.QRCODE)
SEEDS = ("Seeds", FontAwesomeIconConstants.KEY)
TOOLS = ("Tools", FontAwesomeIconConstants.SCREWDRIVER_WRENCH)
SETTINGS = ("Settings", FontAwesomeIconConstants.GEAR)
screen = LargeButtonScreen(
def run(self):
button_data = [self.SCAN, self.SEEDS, self.TOOLS, self.SETTINGS]
selected_menu_num = self.run_screen(
LargeButtonScreen,
title="Home",
title_font_size=26,
button_data=[entry[0] for entry in menu_items],
button_data=button_data,
show_back_button=False,
show_power_button=True,
)
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 .scan_views import ScanView
return Destination(ScanView)
elif button_data[selected_menu_num] == self.SEEDS:
from .seed_views import SeedsMenuView
return Destination(SeedsMenuView)
elif button_data[selected_menu_num] == self.TOOLS:
from .tools_views import ToolsMenuView
return Destination(ToolsMenuView)
elif button_data[selected_menu_num] == self.SETTINGS:
from .settings_views import SettingsMenuView
return Destination(SettingsMenuView)
class PowerOptionsView(View):
def run(self):
RESET = ("Restart", FontAwesomeIconConstants.ROTATE_RIGHT)
POWER_OFF = ("Power Off", FontAwesomeIconConstants.POWER_OFF)
button_data = [RESET, POWER_OFF]
selected_menu_num = LargeButtonScreen(
def run(self):
button_data = [self.RESET, self.POWER_OFF]
selected_menu_num = self.run_screen(
LargeButtonScreen,
title="Reset / Power",
show_back_button=True,
button_data=button_data
).display()
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
elif button_data[selected_menu_num] == RESET:
elif button_data[selected_menu_num] == self.RESET:
return Destination(RestartView)
elif button_data[selected_menu_num] == POWER_OFF:
elif button_data[selected_menu_num] == self.POWER_OFF:
return Destination(PowerOffView)
@@ -176,7 +202,7 @@ class RestartView(View):
def run(self):
thread = RestartView.DoResetThread()
thread.start()
ResetScreen().display()
self.run_screen(ResetScreen)
class DoResetThread(BaseThread):
@@ -200,12 +226,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,30 +249,32 @@ 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]):
def __init__(self, error: list[str]):
self.error = error
def run(self):
DireWarningScreen(
self.run_screen(
DireWarningScreen,
title="System Error",
status_headline=self.error[0],
text=self.error[1] + "\n" + self.error[2],
button_data=["OK"],
show_back_button=False,
allow_text_overflow=True, # Fit what we can, let the rest go off the edges
).display()
)
return Destination(MainMenuView, clear_history=True)
+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
```
+208
View File
@@ -0,0 +1,208 @@
import sys
from dataclasses import dataclass
from mock import MagicMock, 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.views.screensaver'] = MagicMock()
sys.modules['seedsigner.hardware.buttons'] = MagicMock()
sys.modules['seedsigner.hardware.camera'] = MagicMock()
sys.modules['seedsigner.hardware.microsd'] = MagicMock()
from seedsigner.controller import Controller, FlowBasedTestException, StopFlowBasedTest
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON
from seedsigner.models import Settings
from seedsigner.views.view import Destination, MainMenuView, View
class BaseTest:
@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()
@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 and Settings state for each test case """
BaseTest.reset_controller()
BaseTest.reset_settings()
self.controller = Controller.get_instance()
self.settings = Settings.get_instance()
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)
# 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)
+57 -19
View File
@@ -1,34 +1,73 @@
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
BaseTest.reset_controller()
controller = Controller.get_instance()
assert controller.address_explorer_data is None
def test_singleton_init_fails(reset_controller):
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_singleton_get_instance_preserves_state(reset_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:
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:
assert exception_msg in error[2]
else:
assert error[2] == ""
# Initialize the controller
controller = Controller.get_instance()
exception_tests = [
# exceptions with an exception_msg
(Exception, "foo"),
(KeyError, "key not found"),
# exceptions without an exception_msg
(Exception, ""),
(Exception, None),
]
for exception_type, exception_msg in exception_tests:
process_exception_asserting_valid_error(exception_type, exception_msg)
def test_singleton_get_instance_preserves_state(self):
""" Changes to the Controller singleton should be preserved across calls to get_instance() """
# Initialize the instance and verify that it read the config settings
@@ -43,7 +82,7 @@ def test_singleton_get_instance_preserves_state(reset_controller):
assert controller.unverified_address == "123abc"
def test_missing_settings_get_defaults(reset_controller):
def test_missing_settings_get_defaults(self):
""" Should gracefully handle all missing fields from `settings.json` """
controller = Controller.get_instance()
@@ -68,7 +107,6 @@ def test_missing_settings_get_defaults(reset_controller):
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__QR_BRIGHTNESS_TIPS) == SettingsConstants.OPTION__ENABLED
assert controller.settings.get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED
# Hidden Settings defaults
+327
View File
@@ -0,0 +1,327 @@
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])
+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),
])
+56
View File
@@ -0,0 +1,56 @@
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.ScanView, 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=psbt_views.PSBTAddressDetailsView.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.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)
])
+290
View File
@@ -0,0 +1,290 @@
# Must import test base before the Controller
from base import BaseTest, FlowTest, FlowStep
from base import FlowTestUnexpectedViewException, FlowTestRunScreenNotExecutedException, FlowTestInvalidButtonDataSelectionException
import pytest
from seedsigner.models.settings import SettingsConstants
from seedsigner.models.seed import Seed
from seedsigner.views.view import MainMenuView
from seedsigner.views import seed_views, scan_views
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.
"""
def load_seed_into_decoder(view: scan_views.ScanView):
view.decoder.add_data("0000" * 11 + "0003")
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):
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,
}, disable_missing_entries=False)
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),
]
)
+70
View File
@@ -0,0 +1,70 @@
import os
# Must import test base before the Controller
from base import FlowTest, FlowStep
from seedsigner.models import SettingsDefinition
from seedsigner.models.settings import Settings
from seedsigner.models.settings_definition import SettingsConstants
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
from seedsigner.views.view import MainMenuView
from seedsigner.views import settings_views
class TestSettingsFlows(FlowTest):
def test_persistent_settings(self):
""" Basic flow from MainMenuView to enable/disable persistent settings """
# Which option are we testing?
settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS)
# No settings file should exist before we enable persistent settings
assert os.path.exists(Settings.SETTINGS_FILENAME) == False
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS),
FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name),
FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED)),
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON),
FlowStep(settings_views.SettingsMenuView),
])
# Settings file should now exist
assert os.path.exists(Settings.SETTINGS_FILENAME) == True
def test_multiselect(self):
""" Multiselect Settings options should stay in-place; requires BACK to exit. """
# Which option are we testing?
settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS)
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS),
FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name),
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=0), # select/deselect first option
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK to exit
FlowStep(settings_views.SettingsMenuView),
])
def test_io_test(self):
""" Basic flow from MainMenuView to I/O Test View """
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS),
FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.IO_TEST),
FlowStep(settings_views.IOTestView),
FlowStep(settings_views.SettingsMenuView),
])
def test_donate(self):
""" Basic flow from MainMenuView to Donate View """
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS),
FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.DONATE),
FlowStep(settings_views.DonateView),
FlowStep(settings_views.SettingsMenuView),
])
+72
View File
@@ -0,0 +1,72 @@
# Must import test base before the Controller
from base import FlowTest, FlowStep
from seedsigner.controller import Controller
from seedsigner.models.seed import Seed
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
from seedsigner.views.view import MainMenuView
from seedsigner.views import scan_views, seed_views, tools_views
class TestToolsFlows(FlowTest):
def test_addressexplorer_flow(self):
"""
Test the simplest AddressExplorer flow when a seed is already loaded.
"""
controller = Controller.get_instance()
seed = Seed(mnemonic=["abandon "* 11 + "about"])
controller.storage.set_pending_seed(seed)
controller.storage.finalize_pending_seed()
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.EXPLORER),
FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, screen_return_value=0), # ret 1st onboard seed
FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(SettingsConstants.NATIVE_SEGWIT)),
FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE),
FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=10), # ret NEXT page of addrs
FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=4), # ret a specific addr from the list
FlowStep(tools_views.ToolsAddressExplorerAddressView), # runs until dismissed; no ret value
FlowStep(tools_views.ToolsAddressExplorerAddressListView),
])
def test_addressexplorer_loadseed_sideflow(self):
"""
Finalizing a seed during the Address Explorer flow should return to the next
Address Explorer step upon completion.
"""
controller = Controller.get_instance()
def load_seed_into_decoder(view: scan_views.ScanView):
view.decoder.add_data("0000" * 11 + "0003")
# Finalize the new seed w/out passphrase
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.EXPLORER),
FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED),
FlowStep(scan_views.ScanView, before_run=load_seed_into_decoder), # simulate read SeedQR
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
FlowStep(seed_views.SeedExportXpubScriptTypeView),
])
assert controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER
# Reset
controller.storage.seeds.clear()
controller.storage.set_pending_seed(Seed(mnemonic=["abandon "* 11 + "about"]))
# Finalize the new seed w/passphrase
self.run_sequence(
sequence=[
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE),
FlowStep(seed_views.SeedAddPassphraseView, screen_return_value="mypassphrase"),
FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE),
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
FlowStep(seed_views.SeedExportXpubScriptTypeView),
]
)
+74
View File
@@ -0,0 +1,74 @@
from mock import patch
# Must import test base before the Controller
from base import FlowTest, FlowStep
from seedsigner.gui.screens.screen import RET_CODE__POWER_BUTTON
from seedsigner.models.settings import Settings
from seedsigner.views.tools_views import ToolsCalcFinalWordNumWordsView, ToolsMenuView
from seedsigner.views.view import MainMenuView, NotYetImplementedView, PowerOptionsView, PowerOffView, RestartView, UnhandledExceptionView, View
class TestViewFlows(FlowTest):
def test_restart_flow(self):
"""
Basic flow from MainMenuView to RestartView
"""
with patch('seedsigner.views.view.RestartView.DoResetThread'):
self.run_sequence([
FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON),
FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.RESET),
FlowStep(RestartView),
])
def test_power_off_flow(self):
"""
Basic flow from MainMenuView to PowerOffView
"""
with patch('seedsigner.views.view.PowerOffView.PowerOffThread'):
self.run_sequence([
FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON),
FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF),
FlowStep(PowerOffView),
])
# And again, but this time as if we were in the SeedSigner OS
Settings.HOSTNAME = Settings.SEEDSIGNER_OS
self.run_sequence([
FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON),
FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF),
FlowStep(PowerOffView), # returns BackStackView
FlowStep(PowerOptionsView),
])
def test_not_yet_implemented_flow(self):
"""
Run an incomplete View that returns None and ensure that we get the NotYetImplementedView
"""
class IncompleteView(View):
def run(self):
self.run_screen(None)
return None
self.run_sequence([
FlowStep(IncompleteView),
FlowStep(NotYetImplementedView),
FlowStep(MainMenuView),
])
def test_unhandled_exception_flow(self):
"""
Basic flow from any arbitrary View to the UnhandledExceptionView
"""
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD),
FlowStep(ToolsCalcFinalWordNumWordsView, screen_return_value=Exception("Test exception")), # <-- force an exception
FlowStep(UnhandledExceptionView),
FlowStep(MainMenuView),
])
+42 -3
View File
@@ -1,8 +1,47 @@
import pytest
from mock import MagicMock
from seedsigner.models import PSBTParser
from seedsigner.models import PSBTParser, Seed
from embit import psbt
from binascii import a2b_base64
from seedsigner.models.settings_definition import SettingsConstants
def test_sig_count():
print("test")
def test_p2tr_change_detection():
""" Should successfully detect change in a p2tr to p2tr psbt spend
PSBT Tx and Wallet Details
- Single Sig Wallet P2TR (Taproot) with no passphrase
- Regtest 394aed14 m/86'/1'/0' tpubDCawGrRg7YdHdFb9p4mmD8GBaZjJegL53FPFRrMkGoLcgLATJfksUs2y1Q7dVzixAkgecazsxEsUuyj3LyDw7eVVYHQyojwrc2hfesK4wXW
- 1 Inputs
- 3,190,493,401 sats
- 2 Outputs
- 1 Output spend to another wallet (bcrt1p6p00wazu4nnqac29fvky6vhjnnhku5u2g9njss62rvy7e0yuperq86f5ek) p2tr address
- 1 Output change
- addresss bcrt1prz4g6saush37epdwhvwpu78td3q7yfz3xxz37axlx7udck6wracq3rwq30)
- amount 2,871,443,918 sats
- Change addresses is index 1/1
- Fee 155 sats
"""
psbt_base64 = "cHNidP8BAIkCAAAAAf8upuiIWF1VTgC/Q8ZWRrameRigaXpRcQcBe8ye+TK3AQAAAAAXCgAAAs7BJqsAAAAAIlEgGKqNQ7yF4+yFrrscHnjrbEHiJFExhR903ze43FtOH3BwTgQTAAAAACJRINBe93RcrOYO4UVLLE0y8pzvblOKQWcoQ0obCey8nA5GAAAAAE8BBDWHzwNMUx9OgAAAAJdr+WtwWfVa6IPbpKZ4KgRC0clbm11Gl155IPA27n2FAvQCrFGH6Ac2U0Gcy1IH5f5ltgUBDz2+fe8iqL6JzZdgEDlK7RRWAACAAQAAgAAAAIAAAQB9AgAAAAGAKOOUFIzw9pbRDaZ7F0DYhLImrdMn//OSm++ff5VNdAAAAAAAAQAAAAKsjLwAAAAAABYAFKEcuxvXmB3rWHSqSviP5mrKMZoL2RArvgAAAAAiUSBGU0Lg5fx/ECsB1Z4ZUqXQFSLFnlmpm0rm5R2l599h2AAAAAABASvZECu+AAAAACJRIEZTQuDl/H8QKwHVnhlSpdAVIsWeWambSublHaXn32HYAQMEAAAAACEWF7hZVn7pIDR429kAn/WDeQiWjZey1iGHztsL1H83QLMZADlK7RRWAACAAQAAgAAAAIABAAAAAAAAAAEXIBe4WVZ+6SA0eNvZAJ/1g3kIlo2XstYhh87bC9R/N0CzACEHbJdqWyMxF2eOPr6YRXUJmry04HUbgKyeM2IZeG+NI9AZADlK7RRWAACAAQAAgAAAAIABAAAAAQAAAAEFIGyXalsjMRdnjj6+mEV1CZq8tOB1G4CsnjNiGXhvjSPQAAA="
raw = a2b_base64(psbt_base64)
tx = psbt.PSBT.parse(raw)
mnemonic = "goddess rough corn exclude cream trial fee trumpet million prevent gaze power".split()
pw = ""
seed = Seed(mnemonic, passphrase=pw)
pp = PSBTParser(p=tx, seed=seed, network=SettingsConstants.REGTEST)
assert pp.change_data == [
{
'output_index': 0,
'address': 'bcrt1prz4g6saush37epdwhvwpu78td3q7yfz3xxz37axlx7udck6wracq3rwq30',
'amount': 2871443918,
'fingerprint': ['394aed14'],
'derivation_path': ['m/86h/1h/0h/1/1']}
]
assert pp.spend_amount == 319049328
assert pp.change_amount == 2871443918
assert pp.destination_addresses == ['bcrt1p6p00wazu4nnqac29fvky6vhjnnhku5u2g9njss62rvy7e0yuperq86f5ek']
assert pp.destination_amounts == [319049328]
+16
View File
@@ -0,0 +1,16 @@
from base import BaseTest
from seedsigner.models.settings import Settings
from seedsigner.models.settings_definition import SettingsConstants
class TestSettings(BaseTest):
def test_reset_settings(self):
""" BaseTest.reset_settings() should wipe out any previous Settings changes """
settings = Settings.get_instance()
settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, SettingsConstants.OPTION__ENABLED)
assert settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED
BaseTest.reset_settings()
settings = Settings.get_instance()
assert settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED