Merge pull request #275 from LedgerHQ/feat/tests-unit
feat: speculos tests e2e
56
.github/workflows/build-workflow.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Compilation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
nano_debug_build:
|
||||
name: Build debug application for NanoS, X and S+
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- SDK: "$NANOS_SDK"
|
||||
artifact: nanos
|
||||
- SDK: "$NANOX_SDK"
|
||||
artifact: nanox
|
||||
- SDK: "$NANOSP_SDK"
|
||||
artifact: nanosp
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Build an altcoin
|
||||
run: |
|
||||
make BOLOS_SDK=${{ matrix.SDK }} DEBUG=1 ALLOW_DATA=1 CHAIN=ethereum_classic
|
||||
mv bin/app.elf ethereum_classic_${{ matrix.artifact }}.elf
|
||||
|
||||
- name: Upload altcoin binary
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ethereum_classic_${{ matrix.artifact }}
|
||||
path: ./ethereum_classic_${{ matrix.artifact }}.elf
|
||||
|
||||
- name: Build Ethereum
|
||||
run: |
|
||||
make clean
|
||||
make BOLOS_SDK=${{ matrix.SDK }} DEBUG=1 ALLOW_DATA=1
|
||||
mv bin/app.elf ethereum_${{ matrix.artifact }}.elf
|
||||
|
||||
- name: Upload app binary
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ethereum_${{ matrix.artifact }}
|
||||
path: ./ethereum_${{ matrix.artifact }}.elf
|
||||
164
.github/workflows/ci-workflow.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Compilation & tests
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -11,79 +11,6 @@ on:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
job_build_debug_nano_s:
|
||||
name: Build debug Nano S
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
container:
|
||||
image: ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Build an altcoin
|
||||
run: |
|
||||
make DEBUG=1 ALLOW_DATA=1 CHAIN=ethereum_classic
|
||||
mv bin/app.elf ethereum_classic_nanos.elf
|
||||
|
||||
- name: Upload altcoin binary
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ethereum_classic_nanos
|
||||
path: ./ethereum_classic_nanos.elf
|
||||
|
||||
- name: Build Ethereum
|
||||
run: |
|
||||
make clean
|
||||
make DEBUG=1 ALLOW_DATA=1
|
||||
mv bin/app.elf ethereum_nanos.elf
|
||||
|
||||
- name: Upload app binary
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ethereum_nanos
|
||||
path: ./ethereum_nanos.elf
|
||||
|
||||
job_build_debug_nano_x:
|
||||
name: Build debug Nano X
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
container:
|
||||
image: ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Build an altcoin Nano X
|
||||
run: |
|
||||
make clean
|
||||
make BOLOS_SDK=$NANOX_SDK DEBUG=1 ALLOW_DATA=1 CHAIN=ethereum_classic
|
||||
mv bin/app.elf ethereum_classic_nanox.elf
|
||||
|
||||
- name: Upload altcoin binary
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ethereum_classic_nanox
|
||||
path: ./ethereum_classic_nanox.elf
|
||||
|
||||
- name: Build Ethereum Nano X
|
||||
run: |
|
||||
make clean
|
||||
make BOLOS_SDK=$NANOX_SDK DEBUG=1 ALLOW_DATA=1
|
||||
mv bin/app.elf ethereum_nanox.elf
|
||||
|
||||
- name: Upload app binary
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ethereum_nanox
|
||||
path: ./ethereum_nanox.elf
|
||||
|
||||
scan-build:
|
||||
name: Clang Static Analyzer
|
||||
runs-on: ubuntu-latest
|
||||
@@ -104,8 +31,12 @@ jobs:
|
||||
name: scan-build
|
||||
path: scan-build
|
||||
|
||||
building_for_e2e_tests:
|
||||
name: Building binaries for E2E tests
|
||||
# =====================================================
|
||||
# ZEMU TESTS
|
||||
# =====================================================
|
||||
|
||||
building_for_e2e_zemu_tests:
|
||||
name: Building binaries for E2E Zemu tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder:latest
|
||||
@@ -116,17 +47,17 @@ jobs:
|
||||
- name: Build testing binaries
|
||||
run: |
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
cd tests && ./build_local_test_elfs.sh
|
||||
cd tests/zemu/ && ./build_local_test_elfs.sh
|
||||
|
||||
- name: Upload app binaries
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: e2e_elfs
|
||||
path: ./tests/elfs/
|
||||
path: ./tests/zemu/elfs/
|
||||
|
||||
jobs-e2e-tests:
|
||||
name: E2E tests
|
||||
needs: [building_for_e2e_tests]
|
||||
jobs-e2e-zemu-tests:
|
||||
name: E2E Zemu tests
|
||||
needs: [building_for_e2e_zemu_tests]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Test
|
||||
@@ -148,10 +79,10 @@ jobs:
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Build/Install build js deps
|
||||
run: cd tests && yarn install
|
||||
run: cd tests/zemu/ && yarn install
|
||||
|
||||
- name: Create tmp folder for artifacts
|
||||
run: mkdir tests/elfs
|
||||
run: mkdir tests/zemu/elfs
|
||||
|
||||
- name: Download app binaries
|
||||
uses: actions/download-artifact@v2
|
||||
@@ -159,7 +90,70 @@ jobs:
|
||||
path: tmp/
|
||||
|
||||
- name: Gather elfs
|
||||
run: cp `find tmp/e2e_elfs/ -name "*.elf"` tests/elfs/
|
||||
run: cp `find tmp/e2e_elfs/ -name "*.elf"` tests/zemu/elfs/
|
||||
|
||||
- name: Run zemu tests
|
||||
run: cd tests && yarn test
|
||||
run: cd tests/zemu/ && yarn test
|
||||
|
||||
# =====================================================
|
||||
# SPECULOS TESTS
|
||||
# =====================================================
|
||||
|
||||
|
||||
building_for_e2e_speculos_tests:
|
||||
name: Building binaries for E2E Speculos tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder:latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Build testing binaries
|
||||
run: |
|
||||
mkdir tests/speculos/elfs
|
||||
make clean && make DEBUG=1 NFT_TESTING_KEY=1 BOLOS_SDK=$NANOS_SDK && mv bin/app.elf tests/speculos/elfs/nanos.elf
|
||||
make clean && make DEBUG=1 NFT_TESTING_KEY=1 BOLOS_SDK=$NANOX_SDK && mv bin/app.elf tests/speculos/elfs/nanox.elf
|
||||
make clean && make DEBUG=1 NFT_TESTING_KEY=1 BOLOS_SDK=$NANOSP_SDK && mv bin/app.elf tests/speculos/elfs/nanosp.elf
|
||||
|
||||
- name: Upload app binaries
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: e2e_elfs
|
||||
path: ./tests/speculos/elfs
|
||||
|
||||
|
||||
jobs-e2e-speculos-tests:
|
||||
name: Speculos tests
|
||||
strategy:
|
||||
matrix:
|
||||
model: ["nanosp", "nanos", "nanox"]
|
||||
|
||||
needs: [building_for_e2e_speculos_tests]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Create tmp folder for artifacts
|
||||
run: mkdir tests/speculos/elfs
|
||||
|
||||
- name: Download app binaries
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
path: tmp/
|
||||
|
||||
- name: Gather elfs
|
||||
run: cp `find tmp/e2e_elfs/ -name "*.elf"` tests/speculos/elfs/
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd tests/speculos
|
||||
sudo apt-get update && sudo apt-get install -y qemu-user-static
|
||||
pip install --extra-index-url https://test.pypi.org/simple/ -r requirements.txt
|
||||
|
||||
- name: Run speculos tests
|
||||
run: |
|
||||
cd tests/speculos
|
||||
pytest --model ${{ matrix.model }} --path ./elfs/${{ matrix.model }}.elf --display headless
|
||||
1
.gitignore
vendored
@@ -18,3 +18,4 @@ tests/elfs/*
|
||||
tests/snapshots-tmp
|
||||
|
||||
.vscode
|
||||
.idea
|
||||
7
Makefile
@@ -222,13 +222,16 @@ delete:
|
||||
python3 -m ledgerblue.deleteApp $(COMMON_DELETE_PARAMS)
|
||||
|
||||
install_tests:
|
||||
cd tests && (yarn install || sudo yarn install)
|
||||
cd tests/zemu/ && (yarn install || sudo yarn install)
|
||||
|
||||
run_tests:
|
||||
cd tests && (yarn test || sudo yarn test)
|
||||
cd tests/zemu/ && (yarn test || sudo yarn test)
|
||||
|
||||
test: install_tests run_tests
|
||||
|
||||
unit-test:
|
||||
make -C tests/unit
|
||||
|
||||
# import generic rules from the sdk
|
||||
include $(BOLOS_SDK)/Makefile.rules
|
||||
|
||||
|
||||
@@ -94,6 +94,8 @@ The address can be optionally checked on the device before being returned.
|
||||
|
||||
#### Description
|
||||
|
||||
https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1559.md
|
||||
|
||||
This command signs an Ethereum transaction after having the user validate the following parameters
|
||||
|
||||
- Gas price
|
||||
|
||||
25
tests/speculos/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# generated by pip
|
||||
pip-wheel-metadata/
|
||||
|
||||
# pytest debug logs generated via --debug
|
||||
pytestdebug.log
|
||||
.cache
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
50
tests/speculos/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Speculos functional tests
|
||||
|
||||
These tests are implemented in Python with the `SpeculosClient` interface which allows easy execution on the [Speculos](https://github.com/LedgerHQ/speculos) emulator.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [python >= 3.8](https://www.python.org/downloads/)
|
||||
- [pip](https://pip.pypa.io/en/stable/installation/)
|
||||
|
||||
### Dependencies
|
||||
Python dependencies are listed in [requirements.txt](requirements.txt)
|
||||
|
||||
```shell
|
||||
python3 -m pip install --extra-index-url https://test.pypi.org/simple/ -r requirements.txt
|
||||
```
|
||||
> The extra index allows to fetch the latest version of Speculos.
|
||||
|
||||
## Usage
|
||||
|
||||
### Compilation app
|
||||
|
||||
Go to the root of the repository:
|
||||
```sh
|
||||
make DEBUG=1 NFT_TESTING_KEY=1 BOLOS_SDK=$NANOX_SDK
|
||||
mv bin/app.elf tests/speculos/<some name>.elf
|
||||
```
|
||||
|
||||
Given the requirements are installed, just do (by default command):
|
||||
|
||||
```
|
||||
cd tests/speculos/
|
||||
pytest
|
||||
```
|
||||
|
||||
### Custom options
|
||||
- **--model:** "nanos", "nanox", "nanosp" | default: "nanos"
|
||||
- **--display:** "qt", "headless" | default: "qt"
|
||||
- **--path:** the path of the binary app | default: path of makefile compilation
|
||||
|
||||
## Example
|
||||
|
||||
With `nanox` binary app:
|
||||
```sh
|
||||
# the --path is variable to where you put your binary
|
||||
|
||||
pytest --model nanox --path ./elfs/nanox.elf
|
||||
|
||||
# Execute specific test:
|
||||
pytest --model nanox --path ./elfs/nanox.elf test_pubkey_cmd.py
|
||||
```
|
||||
41
tests/speculos/conftest.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from speculos.client import SpeculosClient
|
||||
|
||||
from ethereum_client.ethereum_cmd import EthereumCommand
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).absolute().parent
|
||||
API_URL = "http://127.0.0.1:5000"
|
||||
|
||||
VERSION = {"nanos": "2.1", "nanox": "2.0.2", "nanosp": "1.0.3"}
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
# nanos, nanox, nanosp
|
||||
parser.addoption("--model", action="store", default="nanos")
|
||||
# qt: default, requires a X server
|
||||
# headless: nothing is displayed
|
||||
parser.addoption("--display", action="store", default="qt")
|
||||
|
||||
path: str = SCRIPT_DIR.parent.parent / "bin" / "app.elf"
|
||||
parser.addoption("--path", action="store", default=path)
|
||||
|
||||
@pytest.fixture()
|
||||
def client(pytestconfig):
|
||||
file_path = pytestconfig.getoption("path")
|
||||
model = pytestconfig.getoption("model")
|
||||
|
||||
args = ['--log-level', 'speculos:DEBUG','--model', model, '--display', pytestconfig.getoption("display"), '--sdk', VERSION[model]]
|
||||
with SpeculosClient(app=str(file_path), args=args) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def cmd(client, pytestconfig):
|
||||
yield EthereumCommand(
|
||||
client=client,
|
||||
debug=True,
|
||||
model=pytestconfig.getoption("model"),
|
||||
)
|
||||
133
tests/speculos/docs/README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Documentation of Ethereum's client test
|
||||
|
||||
```sh
|
||||
.
|
||||
├── conftest.py # Configuration for pytest
|
||||
├── ethereum_client # All utils of client test
|
||||
│ ├── ethereum_cmd_builder.py # Creation of apdu to send
|
||||
│ ├── ethereum_cmd.py # Send Apdu and parsing of response
|
||||
│ ├── exception
|
||||
│ │ ├── device_exception.py
|
||||
│ │ └── errors.py
|
||||
│ ├── plugin.py # Creation of content apdu which manage plugin, erc20Information, provide nft information
|
||||
│ ├── transaction.py # Creation of content apdu which manage personal tx, transaction, eip712
|
||||
│ └── utils.py
|
||||
├── requirements.txt
|
||||
├── screenshots # All screenshot of nanoS,X,SP for compare in tests
|
||||
├── setup.cfg
|
||||
|
||||
# ========= All Tests =========
|
||||
├── test_configuration_cmd.py
|
||||
├── test_eip1559.py
|
||||
├── test_eip191.py
|
||||
├── test_eip2930.py
|
||||
├── test_eip712.py
|
||||
├── test_erc1155.py
|
||||
├── test_erc20information.py
|
||||
├── test_erc721.py
|
||||
├── test_pubkey_cmd.py
|
||||
└── test_sign_cmd.py
|
||||
```
|
||||
|
||||
## Ethereum_client
|
||||
|
||||
### Ethereum_cmd_builder
|
||||
```py
|
||||
def chunked(size, source)
|
||||
|
||||
class EthereumCommandBuilder:
|
||||
# Creation of the apdu
|
||||
def get_configuration(self) -> bytes:
|
||||
def set_plugin(self, plugin: Plugin) -> bytes:
|
||||
def provide_nft_information(self, plugin: Plugin) -> bytes:
|
||||
def provide_erc20_token_information(self, info: ERC20Information):
|
||||
def get_public_key(self, bip32_path: str, display: bool = False) -> bytes:
|
||||
def perform_privacy_operation(self, bip32_path: str, display: bool, shared_secret: bool) -> bytes:
|
||||
def simple_sign_tx(self, bip32_path: str, transaction: Transaction) -> bytes:
|
||||
def sign_eip712(self, bip32_path: str, transaction: EIP712) -> bytes:
|
||||
def personal_sign_tx(self, bip32_path: str, transaction: PersonalTransaction) -> Tuple[bool,bytes]:
|
||||
```
|
||||
|
||||
### Ethereum_cmd
|
||||
```py
|
||||
class EthereumCommand:
|
||||
# Sending apdu and parsing the response in the right form
|
||||
def get_configuration(self) -> Tuple[int, int, int, int]:
|
||||
def set_plugin(self, plugin: Plugin):
|
||||
def provide_nft_information(self, plugin: Plugin):
|
||||
def provide_erc20_token_information(self, info: ERC20Information):
|
||||
def get_public_key(self, bip32_path: str, result: List, display: bool = False) -> Tuple[bytes, bytes, bytes]:
|
||||
def perform_privacy_operation(self, bip32_path: str, result: List, display: bool = False, shared_secret: bool = False) -> Tuple[bytes, bytes, bytes]:
|
||||
def simple_sign_tx(self, bip32_path: str, transaction: Transaction, result: List = list()) -> None:
|
||||
def sign_eip712(self, bip32_path: str, transaction: EIP712, result: List = list()) -> None:
|
||||
def personal_sign_tx(self, bip32_path: str, transaction: PersonalTransaction, result: List = list()) -> None:
|
||||
|
||||
|
||||
# Allows to send an apdu without return of speculos
|
||||
def send_apdu(self, apdu: bytes) -> bytes:
|
||||
# Allows to send an apdu with return of speculos
|
||||
def send_apdu_context(self, apdu: bytes, result: List = list()) -> bytes:
|
||||
|
||||
```
|
||||
|
||||
### Utils
|
||||
```py
|
||||
def save_screenshot(cmd, path: str):
|
||||
def compare_screenshot(cmd, path: str):
|
||||
def parse_sign_response(response : bytes) -> Tuple[bytes, bytes, bytes]:
|
||||
def bip32_path_from_string(path: str) -> List[bytes]:
|
||||
def packed_bip32_path_from_string(path: str) -> bytes:
|
||||
def write_varint(n: int) -> bytes:
|
||||
def read_varint(buf: BytesIO, prefix: Optional[bytes] = None) -> int:
|
||||
def read(buf: BytesIO, size: int) -> bytes:
|
||||
def read_uint(buf: BytesIO,
|
||||
```
|
||||
|
||||
## Tests new apdu
|
||||
|
||||
If a new instruction is programmed it will be necessary to create 2 new functions.
|
||||
one in `ethereum_cmd_builder` :
|
||||
- Creation of the raw apdu you can find some examples in this same file
|
||||
|
||||
and one in `ethereum_cmd`:
|
||||
- Send the apdu to speculos and parse the answer in a `list` named result you can find some examples in this same file
|
||||
|
||||
## Example for write new tests
|
||||
|
||||
To send several apdu and get the return
|
||||
|
||||
```py
|
||||
FIRST = bytes.fromhex("{YourAPDU}")
|
||||
SECOND = bytes.fromhex("{YourAPDU}")
|
||||
|
||||
def test_multiple_raw_apdu(cmd):
|
||||
result: list = []
|
||||
|
||||
cmd.send_apdu(FIRST)
|
||||
with cmd.send_apdu_context(SECOND, result) as ex:
|
||||
sleep(0.5)
|
||||
# Here your code for press button and compare screen if you want
|
||||
|
||||
response: bytes = result[0] # response returning
|
||||
# Here you function to parse response of some code
|
||||
v, r, s = parse_sign_response(response)
|
||||
|
||||
# And here assertion of your tests
|
||||
assert v == 0x25 # 37
|
||||
assert r.hex() == "68ba082523584adbfc31d36d68b51d6f209ce0838215026bf1802a8f17dcdff4"
|
||||
assert s.hex() == "7c92908fa05c8bc86507a3d6a1c8b3c2722ee01c836d89a61df60c1ab0b43fff"
|
||||
```
|
||||
|
||||
To test an error
|
||||
|
||||
```py
|
||||
def test_some_error(cmd):
|
||||
result: list = []
|
||||
|
||||
with pytest.raises(ethereum_client.exception.errors.UnknownDeviceError) as error:
|
||||
# With an function in ethereum_cmd
|
||||
with cmd.send_apdu_context(bytes.fromhex("{YourAPDU}"), result) as ex:
|
||||
pass
|
||||
assert error.args[0] == '0x6a80'
|
||||
```
|
||||
|
||||
0
tests/speculos/ethereum_client/__init__.py
Normal file
226
tests/speculos/ethereum_client/ethereum_cmd.py
Normal file
@@ -0,0 +1,226 @@
|
||||
from ast import List
|
||||
from contextlib import contextmanager
|
||||
import struct
|
||||
from time import sleep
|
||||
from typing import Tuple
|
||||
|
||||
from speculos.client import SpeculosClient, ApduException
|
||||
|
||||
from ethereum_client.ethereum_cmd_builder import EthereumCommandBuilder, InsType
|
||||
from ethereum_client.exception import DeviceException
|
||||
from ethereum_client.transaction import EIP712, PersonalTransaction, Transaction
|
||||
from ethereum_client.plugin import ERC20Information, Plugin
|
||||
from ethereum_client.utils import parse_sign_response
|
||||
|
||||
|
||||
class EthereumCommand:
|
||||
def __init__(self,
|
||||
client: SpeculosClient,
|
||||
debug: bool = False,
|
||||
model: str = "nanos") -> None:
|
||||
self.client = client
|
||||
self.builder = EthereumCommandBuilder(debug=debug)
|
||||
self.debug = debug
|
||||
self.model = model
|
||||
|
||||
def get_configuration(self) -> Tuple[int, int, int, int]:
|
||||
try:
|
||||
response = self.client._apdu_exchange(
|
||||
self.builder.get_configuration()
|
||||
) # type: int, bytes
|
||||
except ApduException as error:
|
||||
raise DeviceException(error_code=error.sw, ins=InsType.INS_GET_VERSION)
|
||||
|
||||
# response = FLAG (1) || MAJOR (1) || MINOR (1) || PATCH (1)
|
||||
assert len(response) == 4
|
||||
|
||||
info, major, minor, patch = struct.unpack(
|
||||
"BBBB",
|
||||
response
|
||||
) # type: int, int, int
|
||||
|
||||
return info, major, minor, patch
|
||||
|
||||
def set_plugin(self, plugin: Plugin):
|
||||
try:
|
||||
self.client._apdu_exchange(
|
||||
self.builder.set_plugin(plugin=plugin)
|
||||
)
|
||||
|
||||
except ApduException as error:
|
||||
raise DeviceException(error_code=error.sw, ins=InsType.INS_SET_PLUGIN)
|
||||
|
||||
def provide_nft_information(self, plugin: Plugin):
|
||||
try:
|
||||
self.client._apdu_exchange(
|
||||
self.builder.provide_nft_information(plugin=plugin)
|
||||
)
|
||||
|
||||
except ApduException as error:
|
||||
raise DeviceException(error_code=error.sw, ins=InsType.INS_PROVIDE_NFT_INFORMATION)
|
||||
|
||||
def provide_erc20_token_information(self, info: ERC20Information):
|
||||
try:
|
||||
self.client._apdu_exchange(
|
||||
self.builder.provide_erc20_token_information(info=info)
|
||||
)
|
||||
|
||||
except ApduException as error:
|
||||
raise DeviceException(error_code=error.sw, ins=InsType.INS_PROVIDE_ERC20)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_public_key(self, bip32_path: str, result: List, display: bool = False) -> Tuple[bytes, bytes, bytes]:
|
||||
try:
|
||||
chunk: bytes = self.builder.get_public_key(bip32_path=bip32_path, display=display)
|
||||
|
||||
with self.client.apdu_exchange_nowait(cla=chunk[0], ins=chunk[1],
|
||||
p1=chunk[2], p2=chunk[3],
|
||||
data=chunk[5:]) as exchange:
|
||||
yield exchange
|
||||
response: bytes = exchange.receive()
|
||||
|
||||
except ApduException as error:
|
||||
raise DeviceException(error_code=error.sw, ins=InsType.INS_GET_PUBLIC_KEY)
|
||||
|
||||
# response = pub_key_len (1) ||
|
||||
# pub_key (var) ||
|
||||
# chain_code_len (1) ||
|
||||
# chain_code (var)
|
||||
offset: int = 0
|
||||
|
||||
pub_key_len: int = response[offset]
|
||||
offset += 1
|
||||
|
||||
uncompressed_addr_len: bytes = response[offset:offset + pub_key_len]
|
||||
offset += pub_key_len
|
||||
|
||||
eth_addr_len: int = response[offset]
|
||||
offset += 1
|
||||
|
||||
eth_addr: bytes = response[offset:offset + eth_addr_len]
|
||||
offset += eth_addr_len
|
||||
|
||||
chain_code: bytes = response[offset:]
|
||||
|
||||
assert len(response) == 1 + pub_key_len + 1 + eth_addr_len + 32 # 32 -> chain_code_len
|
||||
|
||||
result.append(uncompressed_addr_len)
|
||||
result.append(eth_addr)
|
||||
result.append(chain_code)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def perform_privacy_operation(self, bip32_path: str, result: List, display: bool = False, shared_secret: bool = False) -> Tuple[bytes, bytes, bytes]:
|
||||
try:
|
||||
chunk: bytes = self.builder.perform_privacy_operation(bip32_path=bip32_path, display=display, shared_secret=shared_secret)
|
||||
|
||||
with self.client.apdu_exchange_nowait(cla=chunk[0], ins=chunk[1],
|
||||
p1=chunk[2], p2=chunk[3],
|
||||
data=chunk[5:]) as exchange:
|
||||
yield exchange
|
||||
response: bytes = exchange.receive()
|
||||
|
||||
except ApduException as error:
|
||||
raise DeviceException(error_code=error.sw, ins=InsType.INS_PERFORM_PRIVACY_OPERATION)
|
||||
|
||||
# response = Public encryption key or shared secret (32)
|
||||
assert len(response) == 32
|
||||
|
||||
result.append(response)
|
||||
|
||||
def send_apdu(self, apdu: bytes) -> bytes:
|
||||
try:
|
||||
self.client.apdu_exchange(cla=apdu[0], ins=apdu[1],
|
||||
p1=apdu[2], p2=apdu[3],
|
||||
data=apdu[5:])
|
||||
except ApduException as error:
|
||||
raise DeviceException(error_code=error.sw, ins=InsType.INS_SIGN_TX)
|
||||
|
||||
@contextmanager
|
||||
def send_apdu_context(self, apdu: bytes, result: List = list()) -> bytes:
|
||||
try:
|
||||
|
||||
with self.client.apdu_exchange_nowait(cla=apdu[0], ins=apdu[1],
|
||||
p1=apdu[2], p2=apdu[3],
|
||||
data=apdu[5:]) as exchange:
|
||||
yield exchange
|
||||
result.append(exchange.receive())
|
||||
|
||||
except ApduException as error:
|
||||
raise DeviceException(error_code=error.sw, ins=InsType.INS_SIGN_TX)
|
||||
|
||||
|
||||
|
||||
@contextmanager
|
||||
def simple_sign_tx(self, bip32_path: str, transaction: Transaction, result: List = list()) -> None:
|
||||
try:
|
||||
chunk: bytes = self.builder.simple_sign_tx(bip32_path=bip32_path, transaction=transaction)
|
||||
|
||||
with self.client.apdu_exchange_nowait(cla=chunk[0], ins=chunk[1],
|
||||
p1=chunk[2], p2=chunk[3],
|
||||
data=chunk[5:]) as exchange:
|
||||
yield exchange
|
||||
response: bytes = exchange.receive()
|
||||
|
||||
except ApduException as error:
|
||||
raise DeviceException(error_code=error.sw, ins=InsType.INS_SIGN_TX)
|
||||
|
||||
# response = V (1) || R (32) || S (32)
|
||||
assert len(response) == 65
|
||||
v, r, s = parse_sign_response(response)
|
||||
|
||||
result.append(v)
|
||||
result.append(r)
|
||||
result.append(s)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def sign_eip712(self, bip32_path: str, transaction: EIP712, result: List = list()) -> None:
|
||||
try:
|
||||
chunk: bytes = self.builder.sign_eip712(bip32_path=bip32_path, transaction=transaction)
|
||||
|
||||
with self.client.apdu_exchange_nowait(cla=chunk[0], ins=chunk[1],
|
||||
p1=chunk[2], p2=chunk[3],
|
||||
data=chunk[5:]) as exchange:
|
||||
yield exchange
|
||||
response: bytes = exchange.receive()
|
||||
|
||||
except ApduException as error:
|
||||
raise DeviceException(error_code=error.sw, ins=InsType.INS_SIGN_EIP712)
|
||||
|
||||
# response = V (1) || R (32) || S (32)
|
||||
assert len(response) == 65
|
||||
v, r, s = parse_sign_response(response)
|
||||
|
||||
result.append(v)
|
||||
result.append(r)
|
||||
result.append(s)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def personal_sign_tx(self, bip32_path: str, transaction: PersonalTransaction, result: List = list()) -> None:
|
||||
try:
|
||||
for islast_apdu, apdu in self.builder.personal_sign_tx(bip32_path=bip32_path, transaction=transaction):
|
||||
if islast_apdu:
|
||||
with self.client.apdu_exchange_nowait(cla=apdu[0], ins=apdu[1],
|
||||
p1=apdu[2], p2=apdu[3],
|
||||
data=apdu[5:]) as exchange:
|
||||
# the "yield" here allows to wait for a button interaction (click right, left, both)
|
||||
yield exchange
|
||||
response: bytes = exchange.receive()
|
||||
else:
|
||||
self.send_apdu(apdu)
|
||||
|
||||
except ApduException as error:
|
||||
raise DeviceException(error_code=error.sw, ins=InsType.INS_SIGN_TX)
|
||||
|
||||
# response = V (1) || R (32) || S (32)
|
||||
v, r, s = parse_sign_response(response)
|
||||
|
||||
result.append(v)
|
||||
result.append(r)
|
||||
result.append(s)
|
||||
|
||||
|
||||
|
||||
293
tests/speculos/ethereum_client/ethereum_cmd_builder.py
Normal file
@@ -0,0 +1,293 @@
|
||||
import enum
|
||||
import logging
|
||||
import struct
|
||||
from typing import List, Tuple, Union, Iterator, cast
|
||||
|
||||
from ethereum_client.transaction import EIP712, PersonalTransaction, Transaction
|
||||
from ethereum_client.plugin import ERC20Information, Plugin
|
||||
from ethereum_client.utils import packed_bip32_path_from_string
|
||||
|
||||
MAX_APDU_LEN: int = 255
|
||||
|
||||
def chunked(size, source):
|
||||
for i in range(0, len(source), size):
|
||||
yield source[i:i+size]
|
||||
|
||||
def chunkify(data: bytes, chunk_len: int) -> Iterator[Tuple[bool, bytes]]:
|
||||
size: int = len(data)
|
||||
|
||||
if size <= chunk_len:
|
||||
yield True, data
|
||||
return
|
||||
|
||||
chunk: int = size // chunk_len
|
||||
remaining: int = size % chunk_len
|
||||
offset: int = 0
|
||||
|
||||
for i in range(chunk):
|
||||
yield False, data[offset:offset + chunk_len]
|
||||
offset += chunk_len
|
||||
|
||||
if remaining:
|
||||
yield True, data[offset:]
|
||||
|
||||
class InsType(enum.IntEnum):
|
||||
INS_GET_PUBLIC_KEY = 0x02
|
||||
INS_SIGN_TX = 0x04
|
||||
INS_GET_CONFIGURATION = 0x06
|
||||
INS_SIGN_PERSONAL_TX = 0x08
|
||||
INS_PROVIDE_ERC20 = 0x0A
|
||||
INS_SIGN_EIP712 = 0x0c
|
||||
INS_ETH2_GET_PUBLIC_KEY = 0x0E
|
||||
INS_SET_ETH2_WITHDRAWAL = 0x10
|
||||
INS_SET_EXTERNAL_PLUGIN = 0x12
|
||||
INS_PROVIDE_NFT_INFORMATION = 0x14
|
||||
INS_SET_PLUGIN = 0x16
|
||||
INS_PERFORM_PRIVACY_OPERATION = 0x18
|
||||
|
||||
|
||||
class EthereumCommandBuilder:
|
||||
"""APDU command builder for the Boilerplate application.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
debug: bool
|
||||
Whether you want to see logging or not.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
debug: bool
|
||||
Whether you want to see logging or not.
|
||||
|
||||
"""
|
||||
CLA: int = 0xE0
|
||||
|
||||
def __init__(self, debug: bool = False):
|
||||
"""Init constructor."""
|
||||
self.debug = debug
|
||||
|
||||
def serialize(self,
|
||||
cla: int,
|
||||
ins: Union[int, enum.IntEnum],
|
||||
p1: int = 0,
|
||||
p2: int = 0,
|
||||
cdata: bytes = b"") -> bytes:
|
||||
"""Serialize the whole APDU command (header + data).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cla : int
|
||||
Instruction class: CLA (1 byte)
|
||||
ins : Union[int, IntEnum]
|
||||
Instruction code: INS (1 byte)
|
||||
p1 : int
|
||||
Instruction parameter 1: P1 (1 byte).
|
||||
p2 : int
|
||||
Instruction parameter 2: P2 (1 byte).
|
||||
cdata : bytes
|
||||
Bytes of command data.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
Bytes of a complete APDU command.
|
||||
|
||||
"""
|
||||
ins = cast(int, ins.value) if isinstance(ins, enum.IntEnum) else cast(int, ins)
|
||||
|
||||
header: bytes = struct.pack("BBBBB",
|
||||
cla,
|
||||
ins,
|
||||
p1,
|
||||
p2,
|
||||
len(cdata)) # add Lc to APDU header
|
||||
|
||||
if self.debug:
|
||||
logging.info("header: %s", header.hex())
|
||||
logging.info("cdata: %s", cdata.hex())
|
||||
|
||||
return header + cdata
|
||||
|
||||
def get_configuration(self) -> bytes:
|
||||
"""Command builder for GET_CONFIGURATON
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
APDU command for GET_CONFIGURATON
|
||||
|
||||
"""
|
||||
return self.serialize(cla=self.CLA,
|
||||
ins=InsType.INS_GET_CONFIGURATION,
|
||||
p1=0x00,
|
||||
p2=0x00,
|
||||
cdata=b"")
|
||||
|
||||
def _same_header_builder(self, data: Union[Plugin, ERC20Information], ins: int) -> bytes:
|
||||
return self.serialize(cla=self.CLA,
|
||||
ins=ins,
|
||||
p1=0x00,
|
||||
p2=0x00,
|
||||
cdata=data.serialize())
|
||||
|
||||
def set_plugin(self, plugin: Plugin) -> bytes:
|
||||
return self._same_header_builder(plugin, InsType.INS_SET_PLUGIN)
|
||||
|
||||
def provide_nft_information(self, plugin: Plugin) -> bytes:
|
||||
return self._same_header_builder(plugin, InsType.INS_PROVIDE_NFT_INFORMATION)
|
||||
|
||||
def provide_erc20_token_information(self, info: ERC20Information):
|
||||
return self._same_header_builder(info, InsType.INS_PROVIDE_ERC20)
|
||||
|
||||
def get_public_key(self, bip32_path: str, display: bool = False) -> bytes:
|
||||
"""Command builder for GET_PUBLIC_KEY.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bip32_path: str
|
||||
String representation of BIP32 path.
|
||||
display : bool
|
||||
Whether you want to display the address on the device.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
APDU command for GET_PUBLIC_KEY.
|
||||
|
||||
"""
|
||||
cdata = packed_bip32_path_from_string(bip32_path)
|
||||
|
||||
return self.serialize(cla=self.CLA,
|
||||
ins=InsType.INS_GET_PUBLIC_KEY,
|
||||
p1=0x01 if display else 0x00,
|
||||
p2=0x01,
|
||||
cdata=cdata)
|
||||
|
||||
def perform_privacy_operation(self, bip32_path: str, display: bool, shared_secret: bool) -> bytes:
|
||||
"""Command builder for INS_PERFORM_PRIVACY_OPERATION.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bip32_path : str
|
||||
String representation of BIP32 path.
|
||||
Third party public key on Curve25519 : 32 bytes
|
||||
Optionnal if returning the shared secret
|
||||
|
||||
"""
|
||||
cdata = packed_bip32_path_from_string(bip32_path)
|
||||
|
||||
return self.serialize(cla=self.CLA,
|
||||
ins=InsType.INS_PERFORM_PRIVACY_OPERATION,
|
||||
p1=0x01 if display else 0x00,
|
||||
p2=0x01 if shared_secret else 0x00,
|
||||
cdata=cdata)
|
||||
|
||||
|
||||
def simple_sign_tx(self, bip32_path: str, transaction: Transaction) -> bytes:
|
||||
"""Command builder for INS_SIGN_TX.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bip32_path : str
|
||||
String representation of BIP32 path.
|
||||
transaction : Transaction
|
||||
Representation of the transaction to be signed.
|
||||
|
||||
Yields
|
||||
-------
|
||||
bytes
|
||||
APDU command chunk for INS_SIGN_TX.
|
||||
|
||||
"""
|
||||
cdata = packed_bip32_path_from_string(bip32_path)
|
||||
|
||||
tx: bytes = transaction.serialize()
|
||||
|
||||
cdata = cdata + tx
|
||||
|
||||
return self.serialize(cla=self.CLA,
|
||||
ins=InsType.INS_SIGN_TX,
|
||||
p1=0x00,
|
||||
p2=0x00,
|
||||
cdata=cdata)
|
||||
|
||||
def sign_eip712(self, bip32_path: str, transaction: EIP712) -> bytes:
|
||||
"""Command builder for INS_SIGN_EIP712.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bip32_path : str
|
||||
String representation of BIP32 path.
|
||||
transaction : EIP712
|
||||
Domain hash -> 32 bytes
|
||||
Message hash -> 32 bytes
|
||||
|
||||
Yields
|
||||
-------
|
||||
bytes
|
||||
APDU command chunk for INS_SIGN_EIP712.
|
||||
|
||||
"""
|
||||
cdata = packed_bip32_path_from_string(bip32_path)
|
||||
|
||||
|
||||
tx: bytes = transaction.serialize()
|
||||
|
||||
cdata = cdata + tx
|
||||
|
||||
return self.serialize(cla=self.CLA,
|
||||
ins=InsType.INS_SIGN_EIP712,
|
||||
p1=0x00,
|
||||
p2=0x00,
|
||||
cdata=cdata)
|
||||
|
||||
def personal_sign_tx(self, bip32_path: str, transaction: PersonalTransaction) -> Tuple[bool,bytes]:
|
||||
"""Command builder for INS_SIGN_PERSONAL_TX.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bip32_path : str
|
||||
String representation of BIP32 path.
|
||||
transaction : Transaction
|
||||
Representation of the transaction to be signed.
|
||||
|
||||
Yields
|
||||
-------
|
||||
bytes
|
||||
APDU command chunk for INS_SIGN_PERSONAL_TX.
|
||||
|
||||
"""
|
||||
|
||||
cdata = packed_bip32_path_from_string(bip32_path)
|
||||
|
||||
tx: bytes = transaction.serialize()
|
||||
|
||||
cdata = cdata + tx
|
||||
last_chunk = len(cdata) // MAX_APDU_LEN
|
||||
|
||||
# The generator allows to send apdu frames because we can't send an apdu > 255
|
||||
for i, (chunk) in enumerate(chunked(MAX_APDU_LEN, cdata)):
|
||||
if i == 0 and i == last_chunk:
|
||||
yield True, self.serialize(cla=self.CLA,
|
||||
ins=InsType.INS_SIGN_PERSONAL_TX,
|
||||
p1=0x00,
|
||||
p2=0x00,
|
||||
cdata=chunk)
|
||||
elif i == 0:
|
||||
yield False, self.serialize(cla=self.CLA,
|
||||
ins=InsType.INS_SIGN_PERSONAL_TX,
|
||||
p1=0x00,
|
||||
p2=0x00,
|
||||
cdata=chunk)
|
||||
elif i == last_chunk:
|
||||
yield True, self.serialize(cla=self.CLA,
|
||||
ins=InsType.INS_SIGN_PERSONAL_TX,
|
||||
p1=0x80,
|
||||
p2=0x00,
|
||||
cdata=chunk)
|
||||
else:
|
||||
yield False, self.serialize(cla=self.CLA,
|
||||
ins=InsType.INS_SIGN_PERSONAL_TX,
|
||||
p1=0x80,
|
||||
p2=0x00,
|
||||
cdata=chunk)
|
||||
35
tests/speculos/ethereum_client/exception/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from .device_exception import DeviceException
|
||||
from .errors import (UnknownDeviceError,
|
||||
DenyError,
|
||||
WrongP1P2Error,
|
||||
WrongDataLengthError,
|
||||
InsNotSupportedError,
|
||||
ClaNotSupportedError,
|
||||
WrongResponseLengthError,
|
||||
DisplayBip32PathFailError,
|
||||
DisplayAddressFailError,
|
||||
DisplayAmountFailError,
|
||||
WrongTxLengthError,
|
||||
TxParsingFailError,
|
||||
TxHashFail,
|
||||
BadStateError,
|
||||
SignatureFailError)
|
||||
|
||||
__all__ = [
|
||||
"DeviceException",
|
||||
"DenyError",
|
||||
"UnknownDeviceError",
|
||||
"WrongP1P2Error",
|
||||
"WrongDataLengthError",
|
||||
"InsNotSupportedError",
|
||||
"ClaNotSupportedError",
|
||||
"WrongResponseLengthError",
|
||||
"DisplayBip32PathFailError",
|
||||
"DisplayAddressFailError",
|
||||
"DisplayAmountFailError",
|
||||
"WrongTxLengthError",
|
||||
"TxParsingFailError",
|
||||
"TxHashFail",
|
||||
"BadStateError",
|
||||
"SignatureFailError"
|
||||
]
|
||||
38
tests/speculos/ethereum_client/exception/device_exception.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import enum
|
||||
from typing import Dict, Any, Union
|
||||
|
||||
from .errors import *
|
||||
|
||||
|
||||
class DeviceException(Exception): # pylint: disable=too-few-public-methods
|
||||
exc: Dict[int, Any] = {
|
||||
0x6985: DenyError,
|
||||
0x6A86: WrongP1P2Error,
|
||||
0x6A87: WrongDataLengthError,
|
||||
0x6D00: InsNotSupportedError,
|
||||
0x6E00: ClaNotSupportedError,
|
||||
0xB000: WrongResponseLengthError,
|
||||
0xB001: DisplayBip32PathFailError,
|
||||
0xB002: DisplayAddressFailError,
|
||||
0xB003: DisplayAmountFailError,
|
||||
0xB004: WrongTxLengthError,
|
||||
0xB005: TxParsingFailError,
|
||||
0xB006: TxHashFail,
|
||||
0xB007: BadStateError,
|
||||
0xB008: SignatureFailError
|
||||
}
|
||||
|
||||
def __new__(cls,
|
||||
error_code: int,
|
||||
ins: Union[int, enum.IntEnum, None] = None,
|
||||
message: str = ""
|
||||
) -> Any:
|
||||
error_message: str = (f"Error in {ins!r} command"
|
||||
if ins else "Error in command")
|
||||
|
||||
if error_code in DeviceException.exc:
|
||||
return DeviceException.exc[error_code](hex(error_code),
|
||||
error_message,
|
||||
message)
|
||||
|
||||
return UnknownDeviceError(hex(error_code), error_message, message)
|
||||
58
tests/speculos/ethereum_client/exception/errors.py
Normal file
@@ -0,0 +1,58 @@
|
||||
class UnknownDeviceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DenyError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class WrongP1P2Error(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class WrongDataLengthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InsNotSupportedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ClaNotSupportedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class WrongResponseLengthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DisplayBip32PathFailError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DisplayAddressFailError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DisplayAmountFailError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class WrongTxLengthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TxParsingFailError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TxHashFail(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BadStateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SignatureFailError(Exception):
|
||||
pass
|
||||
70
tests/speculos/ethereum_client/plugin.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import string
|
||||
from typing import Union
|
||||
|
||||
from ethereum_client.utils import write_varint
|
||||
|
||||
class ERC20Information:
|
||||
def __init__(self, erc20_ticker: string , addr: Union[str, bytes], nb_decimals: int, chainID: int, sign: str) -> None:
|
||||
self.erc20_ticker: bytes = bytes.fromhex(erc20_ticker)
|
||||
self.addr: bytes = bytes.fromhex(addr[2:]) if isinstance(addr, str) else addr
|
||||
self.nb_decimals: int = nb_decimals
|
||||
self.chainID: int = chainID
|
||||
self.sign: bytes = bytes.fromhex(sign)
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
return b"".join([
|
||||
write_varint(len(self.erc20_ticker)),
|
||||
self.erc20_ticker,
|
||||
|
||||
self.addr,
|
||||
|
||||
self.nb_decimals.to_bytes(4, byteorder="big"),
|
||||
|
||||
self.chainID.to_bytes(4, byteorder="big"),
|
||||
|
||||
self.sign,
|
||||
])
|
||||
|
||||
class Plugin:
|
||||
"""Plugin class
|
||||
Allows to generate an apdu of the SET_PLUGIN command or PROVIDE_NFT_INFORMATION
|
||||
|
||||
PROVIDE_NFT_INFORMATION
|
||||
----
|
||||
do not define a selector
|
||||
|
||||
"""
|
||||
def __init__(self, type: int, version: int, name: str, addr: Union[str, bytes], selector: int = -1, chainID: int = 1, keyID: int = 0, algorithm: int = 1, sign: str = "") -> None:
|
||||
self.type: int = type
|
||||
self.version: int = version
|
||||
self.name: bytes = bytes(name, 'UTF-8')
|
||||
self.addr: bytes = bytes.fromhex(addr[2:]) if isinstance(addr, str) else addr
|
||||
self.selector: int = selector
|
||||
self.chainID: int = chainID
|
||||
self.keyID: int = keyID
|
||||
self.algorithm: int = algorithm
|
||||
self.sign: bytes = bytes.fromhex(sign)
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
return b"".join([
|
||||
self.type.to_bytes(1, byteorder="big"),
|
||||
|
||||
self.version.to_bytes(1, byteorder="big"),
|
||||
|
||||
write_varint(len(self.name)),
|
||||
self.name,
|
||||
|
||||
self.addr,
|
||||
|
||||
b'' if self.selector == -1 else self.selector.to_bytes(4, byteorder="big"),
|
||||
|
||||
self.chainID.to_bytes(8, byteorder="big"),
|
||||
|
||||
self.keyID.to_bytes(1, byteorder="big"),
|
||||
|
||||
self.algorithm.to_bytes(1, byteorder="big"),
|
||||
|
||||
write_varint(len(self.sign)),
|
||||
self.sign,
|
||||
|
||||
])
|
||||
96
tests/speculos/ethereum_client/transaction.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from typing import Union
|
||||
|
||||
from ethereum_client.utils import write_varint, UINT64_MAX
|
||||
|
||||
|
||||
class TransactionError(Exception):
|
||||
pass
|
||||
|
||||
EIP2930 = 1
|
||||
EIP1559 = 2
|
||||
|
||||
class PersonalTransaction:
|
||||
def __init__(self, msg: Union[str, bytes]) -> None:
|
||||
# If you want to send bytes directly you have to put "0x" before the string
|
||||
if msg[0:2] == "0x":
|
||||
self.msg: bytes = bytes.fromhex(msg[2:])
|
||||
else:
|
||||
self.msg: bytes = bytes(msg, "utf-8")
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
return b"".join([
|
||||
len(self.msg).to_bytes(4, byteorder="big"),
|
||||
self.msg,
|
||||
])
|
||||
|
||||
class Transaction:
|
||||
def __init__(self, txType: int, nonce: int, gasPrice: int, gasLimit: int, to: Union[str, bytes], value: int, data: Union[str, bytes] = "", chainID: int = -1) -> None:
|
||||
self.txType: int = txType
|
||||
self.nonce: int = nonce
|
||||
self.gasPrice: int = gasPrice
|
||||
self.gasLimit: int = gasLimit
|
||||
self.to: bytes = bytes.fromhex(to[2:]) if isinstance(to, str) else to
|
||||
self.value: int = value
|
||||
self.data: bytes = bytes(data, "utf-8")
|
||||
self.chainID = b''
|
||||
|
||||
if not (0 <= self.nonce <= UINT64_MAX):
|
||||
raise TransactionError(f"Bad nonce: '{self.nonce}'!")
|
||||
|
||||
if not (0 <= self.value <= UINT64_MAX):
|
||||
raise TransactionError(f"Bad value: '{self.value}'!")
|
||||
|
||||
if len(self.to) != 20:
|
||||
raise TransactionError(f"Bad address: '{self.to}'!")
|
||||
|
||||
self.lenNonce = int((len(hex(self.nonce)) - 1) / 2)
|
||||
self.lenGP = int((len(hex(self.gasPrice)) - 1) / 2)
|
||||
self.lenGL = int((len(hex(self.gasLimit)) - 1) / 2)
|
||||
self.lenValue = int((len(hex(self.value)) - 1) / 2)
|
||||
|
||||
self.lenChainID = int((len(hex(chainID)) - 1) / 2)
|
||||
|
||||
if chainID != -1:
|
||||
self.chainID = b"".join([
|
||||
b'' if self.lenChainID == 1 else (self.lenChainID + 0x80).to_bytes(1, byteorder="big"),
|
||||
chainID.to_bytes(self.lenChainID, byteorder="big"),
|
||||
write_varint(0 + 0x80),
|
||||
write_varint(0 + 0x80),
|
||||
])
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
return b"".join([
|
||||
self.txType.to_bytes(1, byteorder="big"),
|
||||
|
||||
b'' if self.lenNonce == 1 else write_varint(self.lenNonce + 0x80),
|
||||
self.nonce.to_bytes(self.lenNonce, byteorder="big"),
|
||||
|
||||
write_varint(self.lenGP + 0x80),
|
||||
self.gasPrice.to_bytes(self.lenGP, byteorder="big"),
|
||||
|
||||
write_varint(self.lenGL + 0x80),
|
||||
self.gasLimit.to_bytes(self.lenGL, byteorder="big"),
|
||||
|
||||
write_varint(len(self.to) + 0x80),
|
||||
self.to,
|
||||
|
||||
write_varint(self.lenValue + 0x80),
|
||||
self.value.to_bytes(self.lenValue, byteorder="big"),
|
||||
|
||||
write_varint(len(self.data) + 0x80),
|
||||
self.data,
|
||||
|
||||
self.chainID,
|
||||
|
||||
])
|
||||
|
||||
class EIP712:
|
||||
def __init__(self, domain_hash: str, msg_hash: str) -> None:
|
||||
self.domain_hash = bytes.fromhex(domain_hash)
|
||||
self.msg_hash = bytes.fromhex(msg_hash)
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
return b"".join([
|
||||
self.domain_hash,
|
||||
self.msg_hash
|
||||
])
|
||||
115
tests/speculos/ethereum_client/utils.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from io import BytesIO
|
||||
from typing import List, Optional, Literal, Tuple
|
||||
import PIL.Image as Image
|
||||
|
||||
import speculos.client
|
||||
|
||||
UINT64_MAX: int = 18446744073709551615
|
||||
UINT32_MAX: int = 4294967295
|
||||
UINT16_MAX: int = 65535
|
||||
|
||||
# Association tableau si écran nanos ou nanox
|
||||
PATH_IMG = {"nanos": "nanos", "nanox": "nanox", "nanosp": "nanox"}
|
||||
|
||||
def save_screenshot(cmd, path: str):
|
||||
screenshot = cmd.client.get_screenshot()
|
||||
img = Image.open(BytesIO(screenshot))
|
||||
img.save(path)
|
||||
|
||||
|
||||
def compare_screenshot(cmd, path: str):
|
||||
screenshot = cmd.client.get_screenshot()
|
||||
assert speculos.client.screenshot_equal(path, BytesIO(screenshot))
|
||||
|
||||
|
||||
def parse_sign_response(response : bytes) -> Tuple[bytes, bytes, bytes]:
|
||||
assert len(response) == 65
|
||||
|
||||
offset: int = 0
|
||||
|
||||
v: bytes = response[offset]
|
||||
offset += 1
|
||||
|
||||
r: bytes = response[offset:offset + 32]
|
||||
offset += 32
|
||||
|
||||
s: bytes = response[offset:]
|
||||
|
||||
return (v, r, s)
|
||||
|
||||
|
||||
def bip32_path_from_string(path: str) -> List[bytes]:
|
||||
splitted_path: List[str] = path.split("/")
|
||||
|
||||
if not splitted_path:
|
||||
raise Exception(f"BIP32 path format error: '{path}'")
|
||||
|
||||
if "m" in splitted_path and splitted_path[0] == "m":
|
||||
splitted_path = splitted_path[1:]
|
||||
|
||||
return [int(p).to_bytes(4, byteorder="big") if "'" not in p
|
||||
else (0x80000000 | int(p[:-1])).to_bytes(4, byteorder="big")
|
||||
for p in splitted_path]
|
||||
|
||||
|
||||
def packed_bip32_path_from_string(path: str) -> bytes:
|
||||
bip32_paths = bip32_path_from_string(path)
|
||||
|
||||
return b"".join([
|
||||
len(bip32_paths).to_bytes(1, byteorder="big"),
|
||||
*bip32_paths
|
||||
])
|
||||
|
||||
|
||||
def write_varint(n: int) -> bytes:
|
||||
if n < 0xFC:
|
||||
return n.to_bytes(1, byteorder="little")
|
||||
|
||||
if n <= UINT16_MAX:
|
||||
return b"\xFD" + n.to_bytes(2, byteorder="little")
|
||||
|
||||
if n <= UINT32_MAX:
|
||||
return b"\xFE" + n.to_bytes(4, byteorder="little")
|
||||
|
||||
if n <= UINT64_MAX:
|
||||
return b"\xFF" + n.to_bytes(8, byteorder="little")
|
||||
|
||||
raise ValueError(f"Can't write to varint: '{n}'!")
|
||||
|
||||
|
||||
def read_varint(buf: BytesIO,
|
||||
prefix: Optional[bytes] = None) -> int:
|
||||
b: bytes = prefix if prefix else buf.read(1)
|
||||
|
||||
if not b:
|
||||
raise ValueError(f"Can't read prefix: '{b}'!")
|
||||
|
||||
n: int = {b"\xfd": 2, b"\xfe": 4, b"\xff": 8}.get(b, 1) # default to 1
|
||||
|
||||
b = buf.read(n) if n > 1 else b
|
||||
|
||||
if len(b) != n:
|
||||
raise ValueError("Can't read varint!")
|
||||
|
||||
return int.from_bytes(b, byteorder="little")
|
||||
|
||||
|
||||
def read(buf: BytesIO, size: int) -> bytes:
|
||||
b: bytes = buf.read(size)
|
||||
|
||||
if len(b) < size:
|
||||
raise ValueError(f"Cant read {size} bytes in buffer!")
|
||||
|
||||
return b
|
||||
|
||||
|
||||
def read_uint(buf: BytesIO,
|
||||
bit_len: int,
|
||||
byteorder: Literal['big', 'little'] = 'little') -> int:
|
||||
size: int = bit_len // 8
|
||||
b: bytes = buf.read(size)
|
||||
|
||||
if len(b) < size:
|
||||
raise ValueError(f"Can't read u{bit_len} in buffer!")
|
||||
|
||||
return int.from_bytes(b, byteorder)
|
||||
5
tests/speculos/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
speculos
|
||||
pytest>=6.1.1,<7.0.0
|
||||
ledgercomm>=1.1.0,<1.2.0
|
||||
ecdsa>=0.16.1,<0.17.0
|
||||
pysha3>=1.0.0,<2.0.0
|
||||
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 374 B |
|
Before Width: | Height: | Size: 296 B After Width: | Height: | Size: 296 B |
|
Before Width: | Height: | Size: 434 B After Width: | Height: | Size: 434 B |
|
Before Width: | Height: | Size: 403 B After Width: | Height: | Size: 403 B |
|
Before Width: | Height: | Size: 343 B After Width: | Height: | Size: 343 B |
|
Before Width: | Height: | Size: 345 B After Width: | Height: | Size: 345 B |
|
Before Width: | Height: | Size: 414 B After Width: | Height: | Size: 414 B |
|
Before Width: | Height: | Size: 414 B After Width: | Height: | Size: 414 B |
|
Before Width: | Height: | Size: 353 B After Width: | Height: | Size: 353 B |
|
Before Width: | Height: | Size: 558 B After Width: | Height: | Size: 558 B |
|
Before Width: | Height: | Size: 413 B After Width: | Height: | Size: 413 B |
|
Before Width: | Height: | Size: 472 B After Width: | Height: | Size: 472 B |
|
Before Width: | Height: | Size: 368 B After Width: | Height: | Size: 368 B |
|
Before Width: | Height: | Size: 463 B After Width: | Height: | Size: 463 B |
|
Before Width: | Height: | Size: 423 B After Width: | Height: | Size: 423 B |
|
Before Width: | Height: | Size: 407 B After Width: | Height: | Size: 407 B |
|
Before Width: | Height: | Size: 368 B After Width: | Height: | Size: 368 B |
|
Before Width: | Height: | Size: 492 B After Width: | Height: | Size: 492 B |
|
Before Width: | Height: | Size: 489 B After Width: | Height: | Size: 489 B |
|
Before Width: | Height: | Size: 474 B After Width: | Height: | Size: 474 B |
|
Before Width: | Height: | Size: 490 B After Width: | Height: | Size: 490 B |
|
Before Width: | Height: | Size: 407 B After Width: | Height: | Size: 407 B |
|
Before Width: | Height: | Size: 368 B After Width: | Height: | Size: 368 B |
|
Before Width: | Height: | Size: 475 B After Width: | Height: | Size: 475 B |
|
Before Width: | Height: | Size: 471 B After Width: | Height: | Size: 471 B |
|
Before Width: | Height: | Size: 465 B After Width: | Height: | Size: 465 B |
|
Before Width: | Height: | Size: 449 B After Width: | Height: | Size: 449 B |
|
Before Width: | Height: | Size: 466 B After Width: | Height: | Size: 466 B |
|
Before Width: | Height: | Size: 407 B After Width: | Height: | Size: 407 B |
|
After Width: | Height: | Size: 368 B |
|
After Width: | Height: | Size: 423 B |
|
Before Width: | Height: | Size: 407 B After Width: | Height: | Size: 407 B |
|
Before Width: | Height: | Size: 449 B After Width: | Height: | Size: 449 B |
|
Before Width: | Height: | Size: 449 B After Width: | Height: | Size: 449 B |
|
Before Width: | Height: | Size: 415 B After Width: | Height: | Size: 415 B |
|
Before Width: | Height: | Size: 624 B After Width: | Height: | Size: 624 B |
|
Before Width: | Height: | Size: 463 B After Width: | Height: | Size: 463 B |
|
Before Width: | Height: | Size: 415 B After Width: | Height: | Size: 415 B |
|
Before Width: | Height: | Size: 882 B After Width: | Height: | Size: 882 B |
|
Before Width: | Height: | Size: 570 B After Width: | Height: | Size: 570 B |
|
Before Width: | Height: | Size: 463 B After Width: | Height: | Size: 463 B |
|
Before Width: | Height: | Size: 415 B After Width: | Height: | Size: 415 B |
|
Before Width: | Height: | Size: 853 B After Width: | Height: | Size: 853 B |
|
Before Width: | Height: | Size: 852 B After Width: | Height: | Size: 852 B |
|
Before Width: | Height: | Size: 837 B After Width: | Height: | Size: 837 B |
|
Before Width: | Height: | Size: 814 B After Width: | Height: | Size: 814 B |
|
Before Width: | Height: | Size: 522 B After Width: | Height: | Size: 522 B |
|
Before Width: | Height: | Size: 463 B After Width: | Height: | Size: 463 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 503 B |
|
Before Width: | Height: | Size: 463 B After Width: | Height: | Size: 463 B |
|
Before Width: | Height: | Size: 499 B After Width: | Height: | Size: 499 B |
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 374 B |
BIN
tests/speculos/screenshots/eip2930/nanos/sign_eip_2930/00001.png
Normal file
|
After Width: | Height: | Size: 307 B |
BIN
tests/speculos/screenshots/eip2930/nanos/sign_eip_2930/00002.png
Normal file
|
After Width: | Height: | Size: 489 B |
BIN
tests/speculos/screenshots/eip2930/nanos/sign_eip_2930/00003.png
Normal file
|
After Width: | Height: | Size: 494 B |
BIN
tests/speculos/screenshots/eip2930/nanos/sign_eip_2930/00004.png
Normal file
|
After Width: | Height: | Size: 391 B |
BIN
tests/speculos/screenshots/eip2930/nanos/sign_eip_2930/00005.png
Normal file
|
After Width: | Height: | Size: 351 B |
BIN
tests/speculos/screenshots/eip2930/nanos/sign_eip_2930/00006.png
Normal file
|
After Width: | Height: | Size: 355 B |
|
Before Width: | Height: | Size: 414 B After Width: | Height: | Size: 414 B |
|
Before Width: | Height: | Size: 414 B After Width: | Height: | Size: 414 B |
BIN
tests/speculos/screenshots/eip2930/nanox/sign_eip_2930/00001.png
Normal file
|
After Width: | Height: | Size: 370 B |
BIN
tests/speculos/screenshots/eip2930/nanox/sign_eip_2930/00002.png
Normal file
|
After Width: | Height: | Size: 766 B |
BIN
tests/speculos/screenshots/eip2930/nanox/sign_eip_2930/00003.png
Normal file
|
After Width: | Height: | Size: 409 B |
BIN
tests/speculos/screenshots/eip2930/nanox/sign_eip_2930/00004.png
Normal file
|
After Width: | Height: | Size: 418 B |
|
Before Width: | Height: | Size: 472 B After Width: | Height: | Size: 472 B |
|
After Width: | Height: | Size: 414 B |
|
After Width: | Height: | Size: 503 B |
|
After Width: | Height: | Size: 497 B |
|
After Width: | Height: | Size: 485 B |
|
After Width: | Height: | Size: 486 B |
|
After Width: | Height: | Size: 540 B |
|
After Width: | Height: | Size: 520 B |
|
After Width: | Height: | Size: 517 B |
|
After Width: | Height: | Size: 501 B |
|
Before Width: | Height: | Size: 407 B After Width: | Height: | Size: 407 B |
|
After Width: | Height: | Size: 414 B |
|
After Width: | Height: | Size: 503 B |
|
After Width: | Height: | Size: 497 B |
|
After Width: | Height: | Size: 485 B |
|
After Width: | Height: | Size: 486 B |