diff --git a/.travis.yml b/.travis.yml index a7603d2..d69ea72 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,7 +15,7 @@ env: - TAG=pywallet-android DOCKERFILE=dockerfiles/Dockerfile-android COMMAND='buildozer android debug' install: - - docker build --tag=$TAG --file=$DOCKERFILE . + - docker build --tag=$TAG --file=$DOCKERFILE --build-arg TRAVIS . script: - - travis_wait docker run $TAG $COMMAND + - travis_wait 30 docker run $TAG $COMMAND diff --git a/Makefile b/Makefile index 2ebde71..62f848f 100644 --- a/Makefile +++ b/Makefile @@ -2,59 +2,57 @@ VENV_NAME=venv ACTIVATE_PATH=$(VENV_NAME)/bin/activate PIP=`. $(ACTIVATE_PATH); which pip` TOX=`which tox` -GARDEN=`. $(ACTIVATE_PATH); which garden` +GARDEN=$(VENV_NAME)/bin/garden PYTHON=$(VENV_NAME)/bin/python -SYSTEM_DEPENDENCIES=virtualenv build-essential libpython2.7-dev \ +SYSTEM_DEPENDENCIES=virtualenv build-essential libpython3.6-dev \ libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev \ cmake python-numpy tox wget curl libssl-dev libzbar-dev \ xclip xsel OS=$(shell lsb_release -si) -OPENCV_VERSION=2.4.13.6 -OPENCV_BASENAME=opencv-$(OPENCV_VERSION) -OPENCV_BUILD=$(OPENCV_BASENAME)/build/lib/cv2.so -OPENCV_DEPLOY=$(VENV_NAME)/lib/python2.7/site-packages/cv2.so -NPROC=`grep -c '^processor' /proc/cpuinfo` +TMPDIR ?= /tmp +DOWNLOAD_DIR = $(TMPDIR)/downloads +KM_REPOSITORY=https://raw.githubusercontent.com/AndreMiras/km +KM_BRANCH=develop +OPENCV_MAKEFILE_NAME=Makefile.opencv +OPENCV_MAKEFILE_URL=$(KM_REPOSITORY)/$(KM_BRANCH)/attachments/$(OPENCV_MAKEFILE_NAME) -all: system_dependencies opencv virtualenv +all: system_dependencies virtualenv -virtualenv: - test -d venv || virtualenv -p python2 venv - . venv/bin/activate +$(VENV_NAME): + test -d $(VENV_NAME) || virtualenv -p python3 $(VENV_NAME) + . $(VENV_NAME)/bin/activate $(PIP) install Cython==0.26.1 $(PIP) install -r requirements/requirements.txt $(GARDEN) install qrcode $(GARDEN) install xcamera +virtualenv: $(VENV_NAME) + system_dependencies: ifeq ($(OS), Ubuntu) sudo apt install --yes --no-install-recommends $(SYSTEM_DEPENDENCIES) endif -$(OPENCV_BUILD): - curl --location https://github.com/opencv/opencv/archive/$(OPENCV_VERSION).tar.gz \ - --progress-bar --output $(OPENCV_BASENAME).tar.gz - tar -xf $(OPENCV_BASENAME).tar.gz - cmake \ - -D BUILD_DOCS=OFF -D BUILD_PACKAGE=OFF -D BUILD_PERF_TESTS=OFF \ - -D BUILD_TESTS=OFF -D BUILD_opencv_apps=OFF \ - -D BUILD_opencv_nonfree=OFF -D BUILD_opencv_stitching=OFF \ - -D BUILD_opencv_superres=OFF -D BUILD_opencv_ts=OFF \ - -D BUILD_WITH_DEBUG_INFO=OFF -D WITH_1394=OFF -D WITH_CUDA=OFF \ - -D WITH_CUFFT=OFF -D WITH_GIGEAPI=OFF -D WITH_JASPER=OFF \ - -D WITH_OPENEXR=OFF -D WITH_PVAPI=OFF -D WITH_GTK=OFF \ - -D BUILD_opencv_python=ON -B$(OPENCV_BASENAME)/build -H$(OPENCV_BASENAME) - cmake --build $(OPENCV_BASENAME)/build -- -j$(NPROC) +$(DOWNLOAD_DIR): + mkdir --parents $(DOWNLOAD_DIR) -opencv_build: $(OPENCV_BUILD) +opencv_build: $(DOWNLOAD_DIR) + curl --location --progress-bar $(OPENCV_MAKEFILE_URL) \ + --output $(DOWNLOAD_DIR)/$(OPENCV_MAKEFILE_NAME) + make --file $(DOWNLOAD_DIR)/$(OPENCV_MAKEFILE_NAME) VENV_NAME=$(VENV_NAME) -$(OPENCV_DEPLOY): opencv_build virtualenv - cp $(OPENCV_BUILD) $(OPENCV_DEPLOY) +opencv_deploy: opencv_build virtualenv + make --file $(DOWNLOAD_DIR)/$(OPENCV_MAKEFILE_NAME) opencv_deploy VENV_NAME=$(VENV_NAME) -opencv: $(OPENCV_DEPLOY) +opencv: opencv_deploy clean: rm -rf $(VENV_NAME) .tox/ $(OPENCV_BASENAME) test: $(TOX) + +uitest: + . $(ACTIVATE_PATH) && \ + $(PYTHON) -m kivyunittest --folder src/tests/ui/ --pythonpath src/ diff --git a/TODO.md b/TODO.md index c8b6011..afd96eb 100644 --- a/TODO.md +++ b/TODO.md @@ -18,29 +18,6 @@ * https://kivy.org/docs/guide/packaging-android.html#packaging-your-application-into-apk * https://python-for-android.readthedocs.io/en/latest/quickstart/#installing-dependencies * https://kivy.org/docs/guide/packaging-android.html - * Upstream - * pydevp2p - * fix broken tests: - * https://github.com/ethereum/pydevp2p/commit/8e1f2b2ef28ecba22bf27eac346bfa7007eaf0fe - * pyethereum - * take a look at the potential unlock wallet performance improvement: - https://github.com/ethereum/pyethereum/pull/777 - * python-for-android - * recipes - * secp256k1 - * `env['CFLAGS'] += ' -I' + join(libsecp256k1_dir, 'include')` # note the `+=` - * `env['INCLUDE_DIR'] = join(libsecp256k1_dir, 'include')` # see secp256k1-py README.md - * `env['LIB_DIR'] = join(libsecp256k1_dir, '.libs')` # see secp256k1-py README.md - * `env['LDFLAGS'] += ' -L' + join(libsecp256k1_dir, '.libs')` # note the `.libs` - * But in fact passing the library path ("LIB_DIR" and "LDFLAGS") may not be required - because libsecp256k1 recipe has `self.install_libs(arch, *libs)` - * libsecp256k1 - * remove java symbols from libsecp256k1 (configure `--enable-jni=no`) to reduce library size - * `should_build()` with `.libs/libsecp256k1.so` check - * Continuous integration - * https://github.com/kivy/python-for-android/issues/625 - * p4a apk --reauirements=kivy --private /tmp/p4a/ --package=org.package.testname --name=testname --boostrap=sdl2 - * buildozer android p4a -- apk ... * MISC * kill running threads on application leave so it doesn't hangs when you quite while the thread tries to connect diff --git a/buildozer.spec b/buildozer.spec index 38ba886..5b739af 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -23,7 +23,7 @@ source.include_exts = py,png,jpg,kv,atlas,md # (list) List of directory to exclude (let empty to not exclude anything) #source.exclude_dirs = tests, bin -source.exclude_dirs = bin, venv, src/python-for-android +source.exclude_dirs = python-for-android # (list) List of exclusions using pattern matching #source.exclude_patterns = license,images/*/*.jpg @@ -38,42 +38,43 @@ version.filename = %(source.dir)s/version.py # (list) Application requirements # comma seperated e.g. requirements = sqlite3,kivy requirements = - hostpython2, - kivy, android, - setuptools, - openssl, - pycryptodome, - pysha3, - ethash, - scrypt, - bitcoin, - rlp==0.6.0, - repoze.lru, - PyYAML, - https://github.com/ethereum/pyethereum/archive/2e53bf3.zip, - devp2p==0.9.3, - coincurve==7.1.0, - gevent, - pbkdf2, - https://github.com/ethereum/pyethapp/archive/409331e88a397ce5276c430aff4a8866d413e45d.zip, - https://gitlab.com/kivymd/KivyMD/repository/archive.zip?ref=e81c02afbca915a4d71c85d3486f6710b53df2c1, - requests, - eth-hash==0.1.1, - pyelliptic==1.5.7, + attrdict==2.0.0, + certifi==2018.10.15, cffi==1.11.5, - libsecp256k1==355a38f, - asn1crypto==0.24.0, - coincurve==7.1.0, - qrcode, - contextlib2, - raven, - libiconv, - libzbar, - zbar, - pil, - https://github.com/AndreMiras/garden.zbarcam/archive/20171220.zip - + chardet==3.0.4, + cytoolz==0.9.0, + eth-abi==1.2.2, + eth-account==0.3.0, + eth-hash==0.1.1, + eth-keyfile==0.5.1, + eth-keys==0.2.0b3, + eth-rlp==0.1.2, + eth-utils==1.4.1, + eth-typing==2.0.0, + gevent, + hexbytes==0.1.0, + https://github.com/AndreMiras/garden.layoutmargin/archive/20180517.tar.gz, + https://github.com/AndreMiras/garden.zbarcam/archive/20190303.zip, + https://github.com/AndreMiras/KivyMD/archive/20181106.tar.gz, + idna==2.7, + Kivy==1.11.1, + libzbar==0.10, + lru-dict==1.1.5, + openssl, + parsimonious==0.8.1, + Pillow==5.2.0, + pycryptodome==3.4.6, + python3, + pyzbar==0.1.8, + qrcode==5.3, + raven==6.1.0, + requests==2.20.0, + rlp==1.0.3, + setuptools==40.9.0, + toolz==0.9.0, + urllib3==1.24.1, + web3==4.8.1 # (str) Custom source folders for requirements # Sets custom source for any requirements with recipes @@ -125,20 +126,22 @@ fullscreen = 0 #android.presplash_color = #FFFFFF # (list) Permissions -#android.permissions = INTERNET android.permissions = INTERNET, CAMERA -# (int) Android API to use -#android.api = 19 +# (int) Target Android API, should be as high as possible. +android.api = 27 -# (int) Minimum API required -#android.minapi = 9 +# (int) Minimum API your APK will support. +android.minapi = 21 # (int) Android SDK version to use -#android.sdk = 20 +android.sdk = 20 # (str) Android NDK version to use -#android.ndk = 9c +android.ndk = 17c + +# (int) Android NDK API to use. This is the minimum API your app will support, it should usually match android.minapi. +android.ndk_api = 21 # (bool) Use --private data storage (True) or --dir public storage (False) #android.private_storage = True @@ -157,6 +160,12 @@ android.permissions = INTERNET, CAMERA # when an update is due and you just want to test/build your package # android.skip_update = False +# (bool) If True, then automatically accept SDK license +# agreements. This is intended for automation only. If set to False, +# the default, you will be shown the license when first running +# buildozer. +android.accept_sdk_license = True + # (str) Android entry point, default is ok for Kivy-based app #android.entrypoint = org.renpy.android.PythonActivity @@ -189,10 +198,6 @@ android.blacklist_src = blacklist.txt # bootstrap) #android.gradle_dependencies = -# (str) python-for-android branch to use, defaults to master -#p4a.branch = stable -p4a.branch = master - # (str) OUYA Console category. Should be one of GAME or APP # If you leave this blank, OUYA support will not be enabled #android.ouya.category = GAME @@ -233,6 +238,12 @@ android.arch = armeabi-v7a # Python for android (p4a) specific # +# (str) python-for-android fork to use, defaults to upstream (kivy) +p4a.fork = kivy + +# (str) python-for-android branch to use, defaults to master +p4a.branch = develop + # (str) python-for-android git clone directory (if empty, it will be automatically cloned from github) #p4a.source_dir = @@ -265,7 +276,7 @@ p4a.local_recipes = %(source.dir)s/python-for-android/recipes/ [buildozer] # (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) -log_level = 1 +log_level = 2 # (int) Display warning if buildozer is run as root (0 = False, 1 = True) warn_on_root = 1 diff --git a/dockerfiles/Dockerfile-android b/dockerfiles/Dockerfile-android index 6086714..261b91f 100644 --- a/dockerfiles/Dockerfile-android +++ b/dockerfiles/Dockerfile-android @@ -9,16 +9,14 @@ # docker run -it --rm pywallet-android FROM ubuntu:18.04 +ARG TRAVIS=false ENV USER="user" ENV HOME_DIR="/home/${USER}" ENV WORK_DIR="${HOME_DIR}" \ PATH="${HOME_DIR}/.local/bin:${PATH}" -ENV DOCKERFILES_VERSION="master" \ +ENV DOCKERFILES_VERSION="develop" \ DOCKERFILES_URL="https://raw.githubusercontent.com/AndreMiras/dockerfiles" -ENV MAKEFILES_URL="${DOCKERFILES_URL}/${DOCKERFILES_VERSION}/buildozer_android_new" -# currently needed because buildozer still uses `tools/android` binary -# even though we have the new `tools/bin/sdkmanager` available -ENV USE_SDK_WRAPPER=true +ENV MAKEFILES_URL="${DOCKERFILES_URL}/${DOCKERFILES_VERSION}/buildozer_android" # configure locale @@ -29,15 +27,31 @@ ENV LANG="en_US.UTF-8" \ LANGUAGE="en_US.UTF-8" \ LC_ALL="en_US.UTF-8" -# install system dependencies (required to setup all the tools) -RUN apt update -qq > /dev/null && apt install -qq --yes --no-install-recommends \ - make curl ca-certificates xz-utils unzip openjdk-8-jdk sudo python-pip \ - python-setuptools - # install build dependencies (required to successfully build the project) -# TODO: should this go to a Makefile instead so it can be shared/reused? RUN apt install -qq --yes --no-install-recommends \ - file autoconf automake libtool gettext pkg-config libltdl-dev + autoconf \ + automake \ + ca-certificates \ + cmake \ + curl \ + gettext \ + libffi-dev \ + libltdl-dev \ + libpython2.7-dev \ + libpython3.6-dev \ + libtool \ + make \ + openjdk-8-jdk \ + pkg-config \ + python3-setuptools \ + python3.6 \ + python3-pip \ + python3-setuptools \ + python \ + sudo \ + unzip \ + xz-utils \ + zip # prepare non root env RUN useradd --create-home --shell /bin/bash ${USER} @@ -48,19 +62,13 @@ RUN echo "%sudo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers USER ${USER} WORKDIR ${WORK_DIR} -# downloads and installs Android SDK -# makes sure to have an up to date build-tools version to workaround buildozer bug, see: -# https://github.com/kivy/buildozer/commit/83ad94d#r29065648 -RUN curl --location --progress-bar ${MAKEFILES_URL}/android_sdk.mk --output android_sdk.mk -RUN make -f android_sdk.mk - # install buildozer and dependencies RUN curl --location --progress-bar ${MAKEFILES_URL}/buildozer.mk --output buildozer.mk RUN make -f buildozer.mk - -# links SDK to where buildozer is expecting to see it -RUN mkdir -p ${HOME}/.buildozer/android/platform -RUN ln -sfn ${HOME}/.android ${HOME}/.buildozer/android/platform/android-sdk-20 +# enforces buildozer master (586152c) until next release +RUN pip3 install --upgrade https://github.com/kivy/buildozer/archive/586152c.zip COPY . ${WORK_DIR} +# limits the amount of logs for Travis +RUN if [ "${TRAVIS}" = "true" ]; then sed 's/log_level = [0-9]/log_level = 1/' -i buildozer.spec; fi ENTRYPOINT ["./dockerfiles/start.sh"] diff --git a/docs/Troubleshoot.md b/docs/Troubleshoot.md index 77910c5..76711c6 100644 --- a/docs/Troubleshoot.md +++ b/docs/Troubleshoot.md @@ -70,6 +70,12 @@ See upstream ticket: https://github.com/kivy/python-for-android/issues/1148 buildozer android p4a -- apk --private $PWD/src/ --local-recipes $PWD/src/python-for-android/recipes/ --package=com.github.andremiras --name PyWallet --version 0.1 --bootstrap=sdl2 --requirements=python2,kivy ``` + +### Uninstaling the app using adb +``` +buildozer android adb -- uninstall com.github.andremiras.pywallet +``` + ## Kivy ### Debugging widget sizes diff --git a/requirements/requirements.txt b/requirements/requirements.txt index f1f4c49..3511930 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,23 +1,10 @@ -# 2017/06/08 develop -https://github.com/ethereum/pyethereum/archive/2e53bf3.zip#egg=pyethereum -devp2p==0.9.3 -# 2017/06/10 develop -https://github.com/ethereum/pyethapp/archive/409331e88a397ce5276c430aff4a8866d413e45d.zip#egg=pyethapp -# https://github.com/ethereum/pyethapp/issues/274 -https://github.com/mfranciszkiewicz/pyelliptic/archive/1.5.10.tar.gz#egg=pyelliptic eth-hash==0.1.1 -pyethash==0.1.27 -rlp==0.6.0 -# on Ubuntu 16.04 Xenial, the error: -# x11 - ImportError: No module named window_x11 -# is fixed by installing kivy master -# https://github.com/kivy/kivy/archive/27e3b90eae2a0155b22a435f1b6f65c913519db6.zip#egg=kivy -# on Ubuntu 18.04 Bionic, the compile error related to `MIX_INIT_MOD` is fixed by installing: -https://github.com/kivy/kivy/archive/d8ef8c2834293098bc404c0432049b2761f9b721.zip#egg=kivy -# Kivy==1.10.0 -Kivy-Garden==0.1.4 -https://gitlab.com/kivymd/KivyMD/repository/archive.zip?ref=e81c02afbca915a4d71c85d3486f6710b53df2c1#egg=kivymd -Pillow==4.1.1 -raven==6.1.0 -https://github.com/AndreMiras/garden.zbarcam/archive/20171220.zip +eth-utils==1.4.1 +https://github.com/AndreMiras/garden.layoutmargin/archive/20180517.tar.gz#egg=layoutmargin +https://github.com/AndreMiras/garden.zbarcam/archive/20190303.zip#egg=zbarcam +https://github.com/AndreMiras/KivyMD/archive/20181106.tar.gz#egg=kivymd +Kivy==1.11.1 +Pillow==5.2.0 qrcode==5.3 +raven==6.1.0 +web3==4.8.1 diff --git a/requirements/test_requirements.txt b/requirements/test_requirements.txt index 8f97763..e49b2f4 100644 --- a/requirements/test_requirements.txt +++ b/requirements/test_requirements.txt @@ -3,5 +3,4 @@ isort==4.2.5 flake8==3.3.0 coverage==4.4.1 -mock==2.0.0 kivyunittest==0.1.8 diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 3b7a076..fa0a1aa 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,5 +1,14 @@ # Change Log +## [v20190812] + + - Migration to Python3, refs #19, #146, #143 + - Handle dynamic permissions, refs #149, #151 + - Configurable gas price + - Configurable network/chainID, refs #1 + - Fix broken Travis build, refs #148 + - Send from different accounts, refs #147 + ## [v20180729] - Linux & Android Travis build on Docker, refs #37 diff --git a/src/ethereum_utils.py b/src/ethereum_utils.py new file mode 100644 index 0000000..30480aa --- /dev/null +++ b/src/ethereum_utils.py @@ -0,0 +1,139 @@ +import logging +import os + +from pyethapp_accounts import Account + + +def get_logger(name): + logger = logging.getLogger(name) + logger.addHandler(logging.StreamHandler()) + logger.setLevel(logging.DEBUG) + return logger + + +log = get_logger(__name__) + + +class AccountUtils: + + def __init__(self, keystore_dir): + self.keystore_dir = keystore_dir + self._accounts = None + os.makedirs(keystore_dir, exist_ok=True) + + def get_account_list(self): + """ + Returns the Account list. + """ + if self._accounts is not None: + return self._accounts + keyfiles = [] + for item in os.listdir(self.keystore_dir): + item_path = os.path.join(self.keystore_dir, item) + if os.path.isfile(item_path): + keyfiles.append(item_path) + # starts caching after `listdir()` call so if it fails + # (e.g. `PermissionError`) account list won't be empty next call + self._accounts = [] + for keyfile in keyfiles: + account = Account.load(path=keyfile) + self._accounts.append(account) + return self._accounts + + def new_account(self, password, key: bytes = None, iterations=None): + """ + Creates an account on the disk and returns it. + :param key: the private key, or `None` to generate a random one + """ + account = Account.new( + password, key=key, uuid=None, iterations=iterations) + account.path = os.path.join(self.keystore_dir, account.address.hex()) + self.add_account(account) + return account + + def add_account(self, account): + account.dump_to_disk() + if self._accounts is None: + self._accounts = [] + self._accounts.append(account) + return account + + @staticmethod + def deleted_account_dir(keystore_dir): + """ + Given a `keystore_dir`, returns the corresponding + `deleted_keystore_dir`. + >>> keystore_dir = '/tmp/keystore' + >>> AccountUtils.deleted_account_dir(keystore_dir) + u'/tmp/keystore-deleted' + >>> keystore_dir = '/tmp/keystore/' + >>> AccountUtils.deleted_account_dir(keystore_dir) + u'/tmp/keystore-deleted' + """ + keystore_dir = keystore_dir.rstrip('/') + keystore_dir_name = os.path.basename(keystore_dir) + deleted_keystore_dir_name = "%s-deleted" % (keystore_dir_name) + deleted_keystore_dir = os.path.join( + os.path.dirname(keystore_dir), + deleted_keystore_dir_name) + return deleted_keystore_dir + + def delete_account(self, account): + """ + Deletes the given `account` from the `keystore_dir` directory. + Then deletes it from the `AccountsService` account manager instance. + In fact, moves it to another location; another directory at the same + level. + """ + # lazy loading + import shutil + keystore_dir = self.keystore_dir + deleted_keystore_dir = self.deleted_account_dir(keystore_dir) + # create the deleted account dir if required + if not os.path.exists(deleted_keystore_dir): + os.makedirs(deleted_keystore_dir) + # "removes" it from the file system + account_filename = os.path.basename(account.path) + deleted_account_path = os.path.join( + deleted_keystore_dir, account_filename) + shutil.move(account.path, deleted_account_path) + self._accounts.remove(account) + + def get_by_address(self, address): + """ + Get an account by its address. + Note that even if an account with the given address exists, it might + not be found if it is locked. + Also, multiple accounts with the same address may exist, in which case + the first one is returned (and a warning is logged). + :raises: `KeyError` if no matching account can be found + """ + assert len(address) == 20 + accounts = [acc for acc in self._accounts if acc.address == address] + if len(accounts) == 0: + raise KeyError( + 'account not found by address', address=address.hex()) + elif len(accounts) > 1: + log.warning( + 'multiple accounts with same address found', + address=address.hex()) + return accounts[0] + + def sign_tx(self, address, tx): + self.get_by_address(address).sign_tx(tx) + + def update_account_password( + self, account, new_password, current_password=None): + """ + Updates the current account instance. + The current_password is optional if the account is already unlocked. + """ + if current_password is not None: + account.unlock(current_password) + new_account = Account.new( + password=new_password, + key=account.privkey, + uuid=account.uuid, + path=account.path) + account.keystore = new_account.keystore + account.dump_to_disk() diff --git a/src/main.py b/src/main.py index 13af02b..092da0e 100755 --- a/src/main.py +++ b/src/main.py @@ -46,7 +46,7 @@ class PyWalletApp(App): def build(self): self.icon = "docs/images/icon.png" - return Controller(info='PyWallet') + return Controller() @property def controller(self): diff --git a/src/pyethapp_accounts.py b/src/pyethapp_accounts.py new file mode 100644 index 0000000..5b134f1 --- /dev/null +++ b/src/pyethapp_accounts.py @@ -0,0 +1,211 @@ +import json +import os + +import eth_account +from eth_keyfile import create_keyfile_json, decode_keyfile_json +from eth_keys import keys +from eth_utils import decode_hex, encode_hex, remove_0x_prefix + + +def to_string(value): + if isinstance(value, bytes): + return value + if isinstance(value, str): + return bytes(value, 'utf-8') + if isinstance(value, int): + return bytes(str(value), 'utf-8') + + +class Account: + """ + Represents an account. + :ivar keystore: the key store as a dictionary (as decoded from json) + :ivar locked: `True` if the account is locked and neither private nor + public keys can be accessed, otherwise `False` + :ivar path: absolute path to the associated keystore file (`None` for + in-memory accounts) + """ + + def __init__(self, keystore: dict, password: bytes = None, path=None): + self.keystore = keystore + try: + self._address = decode_hex(self.keystore['address']) + except KeyError: + self._address = None + self.locked = True + if password is not None: + password = to_string(password) + self.unlock(password) + if path is not None: + self.path = os.path.abspath(path) + else: + self.path = None + + @classmethod + def new(cls, password: bytes, key: bytes = None, uuid=None, path=None, + iterations=None): + """ + Create a new account. + Note that this creates the account in memory and does not store it on + disk. + :param password: the password used to encrypt the private key + :param key: the private key, or `None` to generate a random one + :param uuid: an optional id + """ + if key is None: + account = eth_account.Account.create() + key = account.privateKey + + # [NOTE]: key and password should be bytes + password = str.encode(password) + + # encrypted = eth_account.Account.encrypt(account.privateKey, password) + keystore = create_keyfile_json(key, password, iterations=iterations) + keystore['id'] = uuid + return Account(keystore, password, path) + + @classmethod + def load(cls, path, password: bytes = None): + """ + Load an account from a keystore file. + :param path: full path to the keyfile + :param password: the password to decrypt the key file or `None` to + leave it encrypted + """ + with open(path) as f: + keystore = json.load(f) + # if not keys.check_keystore_json(keystore): + # raise ValueError('Invalid keystore file') + return Account(keystore, password, path=path) + + def dump(self, include_address=True, include_id=True): + """ + Dump the keystore for later disk storage. + The result inherits the entries `'crypto'` and `'version`' from + `account.keystore`, and adds `'address'` and `'id'` in accordance with + the parameters `'include_address'` and `'include_id`'. + If address or id are not known, they are not added, even if requested. + :param include_address: flag denoting if the address should be included + or not + :param include_id: flag denoting if the id should be included or not + """ + d = {} + d['crypto'] = self.keystore['crypto'] + d['version'] = self.keystore['version'] + if include_address and self.address is not None: + d['address'] = encode_hex(self.address) + if include_id and self.uuid is not None: + d['id'] = str(self.uuid) + return json.dumps(d) + + def dump_to_disk(self, include_address=True, include_id=True): + with open(self.path, 'w') as f: + f.write(self.dump(include_address, include_id)) + + def unlock(self, password: bytes): + """ + Unlock the account with a password. + If the account is already unlocked, nothing happens, even if the + password is wrong. + :raises: :exc:`ValueError` (originating in ethereum.keys) if the + password is wrong (and the account is locked) + """ + if self.locked: + password = to_string(password) + self._privkey = decode_keyfile_json(self.keystore, password) + self.locked = False + # get address such that it stays accessible after a subsequent lock + self.address + + def lock(self): + """ + Relock an unlocked account. + This method sets `account.privkey` to `None` (unlike `account.address` + which is preserved). + After calling this method, both `account.privkey` and `account.pubkey` + are `None. + `account.address` stays unchanged, even if it has been derived from the + private key. + """ + self._privkey = None + self.locked = True + + @property + def privkey(self): + """ + The account's private key or `None` if the account is locked + """ + if not self.locked: + return self._privkey + else: + return None + + @property + def pubkey(self): + """ + The account's public key or `None` if the account is locked + """ + if not self.locked: + pk = keys.PrivateKey(self.privkey) + return remove_0x_prefix(pk.public_key.to_address()) + else: + return None + + @property + def address(self): + """ + The account's address or `None` if the address is not stored in the key + file and cannot be reconstructed (because the account is locked) + """ + if self._address: + pass + elif 'address' in self.keystore: + self._address = decode_hex(self.keystore['address']) + elif not self.locked: + pk = keys.PrivateKey(self.privkey) + self._address = decode_hex(pk.public_key.to_address()) + else: + return None + return self._address + + @property + def uuid(self): + """ + An optional unique identifier, formatted according to UUID version 4, + or `None` if the account does not have an id + """ + try: + return self.keystore['id'] + except KeyError: + return None + + @uuid.setter + def uuid(self, value): + """ + Set the UUID. Set it to `None` in order to remove it. + """ + if value is not None: + self.keystore['id'] = value + elif 'id' in self.keystore: + self.keystore.pop('id') + + # TODO: not yet migrated + # def sign_tx(self, tx): + # """ + # Sign a Transaction with the private key of this account. + # If the account is unlocked, this is equivalent to + # `tx.sign(account.privkey)`. + # :param tx: the :class:`ethereum.transactions.Transaction` to sign + # :raises: :exc:`ValueError` if the account is locked + # """ + # if self.privkey: + # tx.sign(self.privkey) + # else: + # raise ValueError('Locked account cannot sign tx') + + def __repr__(self): + if self.address is not None: + address = encode_hex(self.address) + else: + address = '?' + return f'' diff --git a/src/python-for-android/recipes/cffi/__init__.py b/src/python-for-android/recipes/cffi/__init__.py deleted file mode 100644 index 1492b5e..0000000 --- a/src/python-for-android/recipes/cffi/__init__.py +++ /dev/null @@ -1,52 +0,0 @@ -import os -from pythonforandroid.recipe import CompiledComponentsPythonRecipe - - -class CffiRecipe(CompiledComponentsPythonRecipe): - name = 'cffi' - version = '1.11.5' - url = 'https://pypi.python.org/packages/source/c/cffi/cffi-{version}.tar.gz' - - depends = [('python2', 'python3crystax'), 'setuptools', 'pycparser', 'libffi'] - - patches = ['disable-pkg-config.patch'] - - call_hostpython_via_targetpython = False - install_in_hostpython = True - - def get_recipe_env(self, arch=None, with_flags_in_cc=True): - env = super(CffiRecipe, self).get_recipe_env(arch, with_flags_in_cc) - # sets linker to use the correct gcc (cross compiler) - env['LDSHARED'] = env['CC'] + ' -pthread -shared -Wl,-O1 -Wl,-Bsymbolic-functions' - libffi = self.get_recipe('libffi', self.ctx) - includes = libffi.get_include_dirs(arch) - env['CFLAGS'] = ' -I'.join([env.get('CFLAGS', '')] + includes) - env['LDFLAGS'] = (env.get('CFLAGS', '') + ' -L' + - self.ctx.get_libs_dir(arch.arch)) - env['LDFLAGS'] += ' -L{}'.format(os.path.join(self.ctx.bootstrap.build_dir, 'libs', arch.arch)) - # required for libc and libdl - ndk_dir = self.ctx.ndk_platform - ndk_lib_dir = os.path.join(ndk_dir, 'usr', 'lib') - env['LDFLAGS'] += ' -L{}'.format(ndk_lib_dir) - env['LDFLAGS'] += " --sysroot={}".format(self.ctx.ndk_platform) - env['PYTHONPATH'] = ':'.join([ - self.ctx.get_site_packages_dir(), - env['BUILDLIB_PATH'], - ]) - python_version = self.ctx.python_recipe.version[0:3] - if self.ctx.ndk == 'crystax': - # only keeps major.minor (discards patch) - ndk_dir_python = os.path.join(self.ctx.ndk_dir, 'sources/python/', python_version) - env['LDFLAGS'] += ' -L{}'.format(os.path.join(ndk_dir_python, 'libs', arch.arch)) - env['LDFLAGS'] += ' -lpython{}m'.format(python_version) - # until `pythonforandroid/archs.py` gets merged upstream: - # https://github.com/kivy/python-for-android/pull/1250/files#diff-569e13021e33ced8b54385f55b49cbe6 - env['CFLAGS'] += ' -I{}/include/python/'.format(ndk_dir_python) - else: - env['PYTHON_ROOT'] = self.ctx.get_python_install_dir() - env['CFLAGS'] += ' -I' + env['PYTHON_ROOT'] + '/include/python{}'.format(python_version) - env['LDFLAGS'] += " -lpython{}".format(python_version) - return env - - -recipe = CffiRecipe() diff --git a/src/python-for-android/recipes/cffi/disable-pkg-config.patch b/src/python-for-android/recipes/cffi/disable-pkg-config.patch deleted file mode 100644 index 56346bb..0000000 --- a/src/python-for-android/recipes/cffi/disable-pkg-config.patch +++ /dev/null @@ -1,29 +0,0 @@ -diff -Naur cffi-1.4.2/setup.py b/setup.py ---- cffi-1.4.2/setup.py 2015-12-21 12:09:47.000000000 -0600 -+++ b/setup.py 2015-12-23 10:20:40.590622524 -0600 -@@ -5,8 +5,7 @@ - - sources = ['c/_cffi_backend.c'] - libraries = ['ffi'] --include_dirs = ['/usr/include/ffi', -- '/usr/include/libffi'] # may be changed by pkg-config -+include_dirs = [] - define_macros = [] - library_dirs = [] - extra_compile_args = [] -@@ -67,14 +66,7 @@ - sys.stderr.write("The above error message can be safely ignored\n") - - def use_pkg_config(): -- if sys.platform == 'darwin' and os.path.exists('/usr/local/bin/brew'): -- use_homebrew_for_libffi() -- -- _ask_pkg_config(include_dirs, '--cflags-only-I', '-I', sysroot=True) -- _ask_pkg_config(extra_compile_args, '--cflags-only-other') -- _ask_pkg_config(library_dirs, '--libs-only-L', '-L', sysroot=True) -- _ask_pkg_config(extra_link_args, '--libs-only-other') -- _ask_pkg_config(libraries, '--libs-only-l', '-l') -+ pass - - def use_homebrew_for_libffi(): - # We can build by setting: diff --git a/src/python-for-android/recipes/coincurve/__init__.py b/src/python-for-android/recipes/coincurve/__init__.py deleted file mode 100644 index 921655e..0000000 --- a/src/python-for-android/recipes/coincurve/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -import os -from pythonforandroid.recipe import PythonRecipe, CompiledComponentsPythonRecipe - - -class CoincurveRecipe(CompiledComponentsPythonRecipe): - version = '7.1.0' - url = 'https://github.com/ofek/coincurve/archive/{version}.tar.gz' - call_hostpython_via_targetpython = False - depends = [('python2', 'python3crystax'), 'setuptools', - 'libffi', 'cffi', 'libsecp256k1'] - patches = [ - "cross_compile.patch", "drop_setup_requires.patch", - "find_lib.patch", "no-download.patch", "setup.py.patch"] - - def get_recipe_env(self, arch=None, with_flags_in_cc=True): - env = super(CoincurveRecipe, self).get_recipe_env(arch, with_flags_in_cc) - # sets linker to use the correct gcc (cross compiler) - env['LDSHARED'] = env['CC'] + ' -pthread -shared -Wl,-O1 -Wl,-Bsymbolic-functions' - libsecp256k1 = self.get_recipe('libsecp256k1', self.ctx) - libsecp256k1_dir = libsecp256k1.get_build_dir(arch.arch) - env['LDFLAGS'] += ' -L{}'.format(os.path.join(libsecp256k1_dir, '.libs')) - env['CFLAGS'] += ' -I' + os.path.join(libsecp256k1_dir, 'include') - # only keeps major.minor (discards patch) - python_version = self.ctx.python_recipe.version[0:3] - # required additional library and path for Crystax - if self.ctx.ndk == 'crystax': - ndk_dir_python = os.path.join(self.ctx.ndk_dir, 'sources/python/', python_version) - env['LDFLAGS'] += ' -L{}'.format(os.path.join(ndk_dir_python, 'libs', arch.arch)) - env['LDFLAGS'] += ' -lpython{}m'.format(python_version) - # until `pythonforandroid/archs.py` gets merged upstream: - # https://github.com/kivy/python-for-android/pull/1250/files#diff-569e13021e33ced8b54385f55b49cbe6 - env['CFLAGS'] += ' -I{}/include/python/'.format(ndk_dir_python) - else: - env['PYTHON_ROOT'] = self.ctx.get_python_install_dir() - env['CFLAGS'] += ' -I' + env['PYTHON_ROOT'] + '/include/python{}'.format(python_version) - env['LDFLAGS'] += " -lpython{}".format(python_version) - env['LDFLAGS'] += " -lsecp256k1" - return env - - -recipe = CoincurveRecipe() - diff --git a/src/python-for-android/recipes/coincurve/cross_compile.patch b/src/python-for-android/recipes/coincurve/cross_compile.patch deleted file mode 100644 index fbbdd49..0000000 --- a/src/python-for-android/recipes/coincurve/cross_compile.patch +++ /dev/null @@ -1,12 +0,0 @@ -diff --git a/setup.py b/setup.py -index c224fb2..bf925bd 100644 ---- a/setup.py -+++ b/setup.py -@@ -182,6 +182,7 @@ class build_clib(_build_clib): - '--disable-dependency-tracking', - '--with-pic', - '--enable-module-recovery', -+ "--host=%s" % os.environ['TOOLCHAIN_PREFIX'], - '--disable-jni', - '--prefix', - os.path.abspath(self.build_clib), diff --git a/src/python-for-android/recipes/coincurve/drop_setup_requires.patch b/src/python-for-android/recipes/coincurve/drop_setup_requires.patch deleted file mode 100644 index 9994b3f..0000000 --- a/src/python-for-android/recipes/coincurve/drop_setup_requires.patch +++ /dev/null @@ -1,12 +0,0 @@ -diff --git a/setup.py b/setup.py -index c224fb2..466e789 100644 ---- a/setup.py -+++ b/setup.py -@@ -250,7 +250,6 @@ else: - def has_c_libraries(self): - return not has_system_lib() - setup_kwargs = dict( -- setup_requires=['cffi>=1.3.0', 'pytest-runner>=2.6.2'], - ext_package='coincurve', - cffi_modules=['_cffi_build/build.py:ffi'], - cmdclass={ diff --git a/src/python-for-android/recipes/coincurve/find_lib.patch b/src/python-for-android/recipes/coincurve/find_lib.patch deleted file mode 100644 index 3d3c41d..0000000 --- a/src/python-for-android/recipes/coincurve/find_lib.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/setup_support.py b/setup_support.py -index e7a4f2e..72f0c4d 100644 ---- a/setup_support.py -+++ b/setup_support.py -@@ -68,6 +69,8 @@ def build_flags(library, type_, path): - - - def _find_lib(): -+ # we're picking up the recipe one -+ return True - from cffi import FFI - ffi = FFI() - try: diff --git a/src/python-for-android/recipes/coincurve/no-download.patch b/src/python-for-android/recipes/coincurve/no-download.patch deleted file mode 100644 index fcf4d20..0000000 --- a/src/python-for-android/recipes/coincurve/no-download.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/setup.py b/setup.py -index c224fb2..d5f6d1a 100644 ---- a/setup.py -+++ b/setup.py -@@ -51,6 +51,8 @@ if [int(i) for i in setuptools_version.split('.', 2)[:2]] < [3, 3]: - - - def download_library(command): -+ # we will use the custom libsecp256k1 recipe -+ return - if command.dry_run: - return - libdir = absolute('libsecp256k1') diff --git a/src/python-for-android/recipes/coincurve/setup.py.patch b/src/python-for-android/recipes/coincurve/setup.py.patch deleted file mode 100644 index bdc7ab4..0000000 --- a/src/python-for-android/recipes/coincurve/setup.py.patch +++ /dev/null @@ -1,22 +0,0 @@ -From bf3a0684e9b0af29d9777f61d6e7e1d3cc0f2803 Mon Sep 17 00:00:00 2001 -From: Kieran Prasch -Date: Thu, 19 Jul 2018 14:11:48 -0700 -Subject: [PATCH] Exclude tests in setup.py - ---- - setup.py | 2 +- - 1 file changed, 1 insertion(+), 1 deletion(-) - -diff --git a/setup.py b/setup.py -index 0b579f1..0a793ed 100644 ---- a/setup.py -+++ b/setup.py -@@ -277,7 +277,7 @@ def has_c_libraries(self): - install_requires=['asn1crypto', 'cffi>=1.3.0'], - tests_require=['pytest>=2.8.7'], - -- packages=find_packages(exclude=('_cffi_build', '_cffi_build.*', 'libsecp256k1')), -+ packages=find_packages(exclude=('_cffi_build', '_cffi_build.*', 'libsecp256k1', 'tests')), - - distclass=Distribution, - zip_safe=False, diff --git a/src/python-for-android/recipes/hostpython2/Setup b/src/python-for-android/recipes/hostpython2/Setup deleted file mode 100644 index d21c893..0000000 --- a/src/python-for-android/recipes/hostpython2/Setup +++ /dev/null @@ -1,495 +0,0 @@ -# -*- makefile -*- -# The file Setup is used by the makesetup script to construct the files -# Makefile and config.c, from Makefile.pre and config.c.in, -# respectively. The file Setup itself is initially copied from -# Setup.dist; once it exists it will not be overwritten, so you can edit -# Setup to your heart's content. Note that Makefile.pre is created -# from Makefile.pre.in by the toplevel configure script. - -# (VPATH notes: Setup and Makefile.pre are in the build directory, as -# are Makefile and config.c; the *.in and *.dist files are in the source -# directory.) - -# Each line in this file describes one or more optional modules. -# Modules enabled here will not be compiled by the setup.py script, -# so the file can be used to override setup.py's behavior. - -# Lines have the following structure: -# -# ... [ ...] [ ...] [ ...] -# -# is anything ending in .c (.C, .cc, .c++ are C++ files) -# is anything starting with -I, -D, -U or -C -# is anything ending in .a or beginning with -l or -L -# is anything else but should be a valid Python -# identifier (letters, digits, underscores, beginning with non-digit) -# -# (As the makesetup script changes, it may recognize some other -# arguments as well, e.g. *.so and *.sl as libraries. See the big -# case statement in the makesetup script.) -# -# Lines can also have the form -# -# = -# -# which defines a Make variable definition inserted into Makefile.in -# -# Finally, if a line contains just the word "*shared*" (without the -# quotes but with the stars), then the following modules will not be -# built statically. The build process works like this: -# -# 1. Build all modules that are declared as static in Modules/Setup, -# combine them into libpythonxy.a, combine that into python. -# 2. Build all modules that are listed as shared in Modules/Setup. -# 3. Invoke setup.py. That builds all modules that -# a) are not builtin, and -# b) are not listed in Modules/Setup, and -# c) can be build on the target -# -# Therefore, modules declared to be shared will not be -# included in the config.c file, nor in the list of objects to be -# added to the library archive, and their linker options won't be -# added to the linker options. Rules to create their .o files and -# their shared libraries will still be added to the Makefile, and -# their names will be collected in the Make variable SHAREDMODS. This -# is used to build modules as shared libraries. (They can be -# installed using "make sharedinstall", which is implied by the -# toplevel "make install" target.) (For compatibility, -# *noconfig* has the same effect as *shared*.) -# -# In addition, *static* explicitly declares the following modules to -# be static. Lines containing "*static*" and "*shared*" may thus -# alternate throughout this file. - -# NOTE: As a standard policy, as many modules as can be supported by a -# platform should be present. The distribution comes with all modules -# enabled that are supported by most platforms and don't require you -# to ftp sources from elsewhere. - - -# Some special rules to define PYTHONPATH. -# Edit the definitions below to indicate which options you are using. -# Don't add any whitespace or comments! - -# Directories where library files get installed. -# DESTLIB is for Python modules; MACHDESTLIB for shared libraries. -DESTLIB=$(LIBDEST) -MACHDESTLIB=$(BINLIBDEST) - -# NOTE: all the paths are now relative to the prefix that is computed -# at run time! - -# Standard path -- don't edit. -# No leading colon since this is the first entry. -# Empty since this is now just the runtime prefix. -DESTPATH= - -# Site specific path components -- should begin with : if non-empty -SITEPATH= - -# Standard path components for test modules -TESTPATH= - -# Path components for machine- or system-dependent modules and shared libraries -MACHDEPPATH=:plat-$(MACHDEP) -EXTRAMACHDEPPATH= - -# Path component for the Tkinter-related modules -# The TKPATH variable is always enabled, to save you the effort. -TKPATH=:lib-tk - -# Path component for old modules. -OLDPATH=:lib-old - -COREPYTHONPATH=$(DESTPATH)$(SITEPATH)$(TESTPATH)$(MACHDEPPATH)$(EXTRAMACHDEPPATH)$(TKPATH)$(OLDPATH) -PYTHONPATH=$(COREPYTHONPATH) - - -# The modules listed here can't be built as shared libraries for -# various reasons; therefore they are listed here instead of in the -# normal order. - -# This only contains the minimal set of modules required to run the -# setup.py script in the root of the Python source tree. - -posix posixmodule.c # posix (UNIX) system calls -errno errnomodule.c # posix (UNIX) errno values -pwd pwdmodule.c # this is needed to find out the user's home dir - # if $HOME is not set -_sre _sre.c # Fredrik Lundh's new regular expressions -_codecs _codecsmodule.c # access to the builtin codecs and codec registry - -# The zipimport module is always imported at startup. Having it as a -# builtin module avoids some bootstrapping problems and reduces overhead. -zipimport zipimport.c - -# The rest of the modules listed in this file are all commented out by -# default. Usually they can be detected and built as dynamically -# loaded modules by the new setup.py script added in Python 2.1. If -# you're on a platform that doesn't support dynamic loading, want to -# compile modules statically into the Python binary, or need to -# specify some odd set of compiler switches, you can uncomment the -# appropriate lines below. - -# ====================================================================== - -# The Python symtable module depends on .h files that setup.py doesn't track -_symtable symtablemodule.c - -# The SGI specific GL module: - -GLHACK=-Dclear=__GLclear -#gl glmodule.c cgensupport.c -I$(srcdir) $(GLHACK) -lgl -lX11 - -# Pure module. Cannot be linked dynamically. -# -DWITH_QUANTIFY, -DWITH_PURIFY, or -DWITH_ALL_PURE -#WHICH_PURE_PRODUCTS=-DWITH_ALL_PURE -#PURE_INCLS=-I/usr/local/include -#PURE_STUBLIBS=-L/usr/local/lib -lpurify_stubs -lquantify_stubs -#pure puremodule.c $(WHICH_PURE_PRODUCTS) $(PURE_INCLS) $(PURE_STUBLIBS) - -# Uncommenting the following line tells makesetup that all following -# modules are to be built as shared libraries (see above for more -# detail; also note that *static* reverses this effect): - -#*shared* - -# GNU readline. Unlike previous Python incarnations, GNU readline is -# now incorporated in an optional module, configured in the Setup file -# instead of by a configure script switch. You may have to insert a -# -L option pointing to the directory where libreadline.* lives, -# and you may have to change -ltermcap to -ltermlib or perhaps remove -# it, depending on your system -- see the GNU readline instructions. -# It's okay for this to be a shared library, too. - -#readline readline.c -lreadline -ltermcap - - -# Modules that should always be present (non UNIX dependent): - -array arraymodule.c # array objects -cmath cmathmodule.c # -lm # complex math library functions -math mathmodule.c # -lm # math library functions, e.g. sin() -_struct _struct.c # binary structure packing/unpacking -time timemodule.c # -lm # time operations and variables -operator operator.c # operator.add() and similar goodies -_weakref _weakref.c # basic weak reference support -#_testcapi _testcapimodule.c # Python C API test module -_random _randommodule.c # Random number generator -_collections _collectionsmodule.c # Container types -itertools itertoolsmodule.c # Functions creating iterators for efficient looping -strop stropmodule.c # String manipulations -_functools _functoolsmodule.c # Tools for working with functions and callable objects -_elementtree -I$(srcdir)/Modules/expat -DHAVE_EXPAT_CONFIG_H -DUSE_PYEXPAT_CAPI _elementtree.c # elementtree accelerator -#_pickle _pickle.c # pickle accelerator -datetime datetimemodule.c # date/time type -_bisect _bisectmodule.c # Bisection algorithms - -#unicodedata unicodedata.c # static Unicode character database - -# access to ISO C locale support -#_locale _localemodule.c # -lintl - - -# Modules with some UNIX dependencies -- on by default: -# (If you have a really backward UNIX, select and socket may not be -# supported...) - -fcntl fcntlmodule.c # fcntl(2) and ioctl(2) -#spwd spwdmodule.c # spwd(3) -#grp grpmodule.c # grp(3) -select selectmodule.c # select(2); not on ancient System V - -# Memory-mapped files (also works on Win32). -#mmap mmapmodule.c - -# CSV file helper -#_csv _csv.c - -# Socket module helper for socket(2) -_socket socketmodule.c - -# Socket module helper for SSL support; you must comment out the other -# socket line above, and possibly edit the SSL variable: -#SSL=/usr/local/ssl -#_ssl _ssl.c \ -# -DUSE_SSL -I$(SSL)/include -I$(SSL)/include/openssl \ -# -L$(SSL)/lib -lssl -lcrypto - -# The crypt module is now disabled by default because it breaks builds -# on many systems (where -lcrypt is needed), e.g. Linux (I believe). -# -# First, look at Setup.config; configure may have set this for you. - -#crypt cryptmodule.c # -lcrypt # crypt(3); needs -lcrypt on some systems - - -# Some more UNIX dependent modules -- off by default, since these -# are not supported by all UNIX systems: - -#nis nismodule.c -lnsl # Sun yellow pages -- not everywhere -#termios termios.c # Steen Lumholt's termios module -#resource resource.c # Jeremy Hylton's rlimit interface - - -# Multimedia modules -- off by default. -# These don't work for 64-bit platforms!!! -# #993173 says audioop works on 64-bit platforms, though. -# These represent audio samples or images as strings: - -#audioop audioop.c # Operations on audio samples -#imageop imageop.c # Operations on images - - -# Note that the _md5 and _sha modules are normally only built if the -# system does not have the OpenSSL libs containing an optimized version. - -# The _md5 module implements the RSA Data Security, Inc. MD5 -# Message-Digest Algorithm, described in RFC 1321. The necessary files -# md5.c and md5.h are included here. - -_md5 md5module.c md5.c - - -# The _sha module implements the SHA checksum algorithms. -# (NIST's Secure Hash Algorithms.) -_sha shamodule.c -_sha256 sha256module.c -_sha512 sha512module.c - - -# SGI IRIX specific modules -- off by default. - -# These module work on any SGI machine: - -# *** gl must be enabled higher up in this file *** -#fm fmmodule.c $(GLHACK) -lfm -lgl # Font Manager -#sgi sgimodule.c # sgi.nap() and a few more - -# This module requires the header file -# /usr/people/4Dgifts/iristools/include/izoom.h: -#imgfile imgfile.c -limage -lgutil -lgl -lm # Image Processing Utilities - - -# These modules require the Multimedia Development Option (I think): - -#al almodule.c -laudio # Audio Library -#cd cdmodule.c -lcdaudio -lds -lmediad # CD Audio Library -#cl clmodule.c -lcl -lawareaudio # Compression Library -#sv svmodule.c yuvconvert.c -lsvideo -lXext -lX11 # Starter Video - - -# The FORMS library, by Mark Overmars, implements user interface -# components such as dialogs and buttons using SGI's GL and FM -# libraries. You must ftp the FORMS library separately from -# ftp://ftp.cs.ruu.nl/pub/SGI/FORMS. It was tested with FORMS 2.2a. -# NOTE: if you want to be able to use FORMS and curses simultaneously -# (or both link them statically into the same binary), you must -# compile all of FORMS with the cc option "-Dclear=__GLclear". - -# The FORMS variable must point to the FORMS subdirectory of the forms -# toplevel directory: - -#FORMS=/ufs/guido/src/forms/FORMS -#fl flmodule.c -I$(FORMS) $(GLHACK) $(FORMS)/libforms.a -lfm -lgl - - -# SunOS specific modules -- off by default: - -#sunaudiodev sunaudiodev.c - - -# A Linux specific module -- off by default; this may also work on -# some *BSDs. - -#linuxaudiodev linuxaudiodev.c - - -# George Neville-Neil's timing module: - -#timing timingmodule.c - - -# The _tkinter module. -# -# The command for _tkinter is long and site specific. Please -# uncomment and/or edit those parts as indicated. If you don't have a -# specific extension (e.g. Tix or BLT), leave the corresponding line -# commented out. (Leave the trailing backslashes in! If you -# experience strange errors, you may want to join all uncommented -# lines and remove the backslashes -- the backslash interpretation is -# done by the shell's "read" command and it may not be implemented on -# every system. - -# *** Always uncomment this (leave the leading underscore in!): -# _tkinter _tkinter.c tkappinit.c -DWITH_APPINIT \ -# *** Uncomment and edit to reflect where your Tcl/Tk libraries are: -# -L/usr/local/lib \ -# *** Uncomment and edit to reflect where your Tcl/Tk headers are: -# -I/usr/local/include \ -# *** Uncomment and edit to reflect where your X11 header files are: -# -I/usr/X11R6/include \ -# *** Or uncomment this for Solaris: -# -I/usr/openwin/include \ -# *** Uncomment and edit for Tix extension only: -# -DWITH_TIX -ltix8.1.8.2 \ -# *** Uncomment and edit for BLT extension only: -# -DWITH_BLT -I/usr/local/blt/blt8.0-unoff/include -lBLT8.0 \ -# *** Uncomment and edit for PIL (TkImaging) extension only: -# (See http://www.pythonware.com/products/pil/ for more info) -# -DWITH_PIL -I../Extensions/Imaging/libImaging tkImaging.c \ -# *** Uncomment and edit for TOGL extension only: -# -DWITH_TOGL togl.c \ -# *** Uncomment and edit to reflect your Tcl/Tk versions: -# -ltk8.2 -ltcl8.2 \ -# *** Uncomment and edit to reflect where your X11 libraries are: -# -L/usr/X11R6/lib \ -# *** Or uncomment this for Solaris: -# -L/usr/openwin/lib \ -# *** Uncomment these for TOGL extension only: -# -lGL -lGLU -lXext -lXmu \ -# *** Uncomment for AIX: -# -lld \ -# *** Always uncomment this; X11 libraries to link with: -# -lX11 - -# Lance Ellinghaus's syslog module -#syslog syslogmodule.c # syslog daemon interface - - -# Curses support, requring the System V version of curses, often -# provided by the ncurses library. e.g. on Linux, link with -lncurses -# instead of -lcurses). -# -# First, look at Setup.config; configure may have set this for you. - -#_curses _cursesmodule.c -lcurses -ltermcap -# Wrapper for the panel library that's part of ncurses and SYSV curses. -#_curses_panel _curses_panel.c -lpanel -lncurses - - -# Generic (SunOS / SVR4) dynamic loading module. -# This is not needed for dynamic loading of Python modules -- -# it is a highly experimental and dangerous device for calling -# *arbitrary* C functions in *arbitrary* shared libraries: - -#dl dlmodule.c - - -# Modules that provide persistent dictionary-like semantics. You will -# probably want to arrange for at least one of them to be available on -# your machine, though none are defined by default because of library -# dependencies. The Python module anydbm.py provides an -# implementation independent wrapper for these; dumbdbm.py provides -# similar functionality (but slower of course) implemented in Python. - -# The standard Unix dbm module has been moved to Setup.config so that -# it will be compiled as a shared library by default. Compiling it as -# a built-in module causes conflicts with the pybsddb3 module since it -# creates a static dependency on an out-of-date version of db.so. -# -# First, look at Setup.config; configure may have set this for you. - -#dbm dbmmodule.c # dbm(3) may require -lndbm or similar - -# Anthony Baxter's gdbm module. GNU dbm(3) will require -lgdbm: -# -# First, look at Setup.config; configure may have set this for you. - -#gdbm gdbmmodule.c -I/usr/local/include -L/usr/local/lib -lgdbm - - -# Sleepycat Berkeley DB interface. -# -# This requires the Sleepycat DB code, see http://www.sleepycat.com/ -# The earliest supported version of that library is 3.0, the latest -# supported version is 4.0 (4.1 is specifically not supported, as that -# changes the semantics of transactional databases). A list of available -# releases can be found at -# -# http://www.sleepycat.com/update/index.html -# -# Edit the variables DB and DBLIBVERto point to the db top directory -# and the subdirectory of PORT where you built it. -#DB=/usr/local/BerkeleyDB.4.0 -#DBLIBVER=4.0 -#DBINC=$(DB)/include -#DBLIB=$(DB)/lib -#_bsddb _bsddb.c -I$(DBINC) -L$(DBLIB) -ldb-$(DBLIBVER) - -# Historical Berkeley DB 1.85 -# -# This module is deprecated; the 1.85 version of the Berkeley DB library has -# bugs that can cause data corruption. If you can, use later versions of the -# library instead, available from . - -#DB=/depot/sundry/src/berkeley-db/db.1.85 -#DBPORT=$(DB)/PORT/irix.5.3 -#bsddb185 bsddbmodule.c -I$(DBPORT)/include -I$(DBPORT) $(DBPORT)/libdb.a - - - -# Helper module for various ascii-encoders -binascii binascii.c - -# Fred Drake's interface to the Python parser -parser parsermodule.c - -# cStringIO and cPickle -cStringIO cStringIO.c -cPickle cPickle.c - - -# Lee Busby's SIGFPE modules. -# The library to link fpectl with is platform specific. -# Choose *one* of the options below for fpectl: - -# For SGI IRIX (tested on 5.3): -#fpectl fpectlmodule.c -lfpe - -# For Solaris with SunPro compiler (tested on Solaris 2.5 with SunPro C 4.2): -# (Without the compiler you don't have -lsunmath.) -#fpectl fpectlmodule.c -R/opt/SUNWspro/lib -lsunmath -lm - -# For other systems: see instructions in fpectlmodule.c. -#fpectl fpectlmodule.c ... - -# Test module for fpectl. No extra libraries needed. -#fpetest fpetestmodule.c - -# Andrew Kuchling's zlib module. -# This require zlib 1.1.3 (or later). -# See http://www.gzip.org/zlib/ -zlib zlibmodule.c -I$(prefix)/include -L$(exec_prefix)/lib -lz - -# Interface to the Expat XML parser -# -# Expat was written by James Clark and is now maintained by a group of -# developers on SourceForge; see www.libexpat.org for more -# information. The pyexpat module was written by Paul Prescod after a -# prototype by Jack Jansen. Source of Expat 1.95.2 is included in -# Modules/expat/. Usage of a system shared libexpat.so/expat.dll is -# not advised. -# -# More information on Expat can be found at www.libexpat.org. -# -pyexpat expat/xmlparse.c expat/xmlrole.c expat/xmltok.c pyexpat.c -I$(srcdir)/Modules/expat -DHAVE_EXPAT_CONFIG_H -DUSE_PYEXPAT_CAPI - - -# Hye-Shik Chang's CJKCodecs - -# multibytecodec is required for all the other CJK codec modules -#_multibytecodec cjkcodecs/multibytecodec.c - -#_codecs_cn cjkcodecs/_codecs_cn.c -#_codecs_hk cjkcodecs/_codecs_hk.c -#_codecs_iso2022 cjkcodecs/_codecs_iso2022.c -#_codecs_jp cjkcodecs/_codecs_jp.c -#_codecs_kr cjkcodecs/_codecs_kr.c -#_codecs_tw cjkcodecs/_codecs_tw.c - -# Example -- included for reference only: -# xx xxmodule.c - -# Another example -- the 'xxsubtype' module shows C-level subtyping in action -xxsubtype xxsubtype.c diff --git a/src/python-for-android/recipes/hostpython2/__init__.py b/src/python-for-android/recipes/hostpython2/__init__.py deleted file mode 100644 index 4679135..0000000 --- a/src/python-for-android/recipes/hostpython2/__init__.py +++ /dev/null @@ -1,69 +0,0 @@ - -from pythonforandroid.toolchain import Recipe, shprint, current_directory, info, warning -from os.path import join, exists -import os -import sh - - -class Hostpython2Recipe(Recipe): - """ - Overrides upstream hostpython2 recipe from: - https://github.com/kivy/python-for-android/blob/b3d3d45 - /pythonforandroid/recipes/hostpython2/__init__.py - Adds `--enable-unicode=ucs4` configure flag, refs: - https://github.com/AndreMiras/PyWallet/issues/136 - """ - version = '2.7.2' - url = 'https://python.org/ftp/python/{version}/Python-{version}.tar.bz2' - name = 'hostpython2' - - conflicts = ['hostpython3'] - - def get_build_container_dir(self, arch=None): - choices = self.check_recipe_choices() - dir_name = '-'.join([self.name] + choices) - return join(self.ctx.build_dir, 'other_builds', dir_name, 'desktop') - - def get_build_dir(self, arch=None): - return join(self.get_build_container_dir(), self.name) - - def prebuild_arch(self, arch): - # Override hostpython Setup? - shprint(sh.cp, join(self.get_recipe_dir(), 'Setup'), - join(self.get_build_dir(), 'Modules', 'Setup')) - - def build_arch(self, arch): - with current_directory(self.get_build_dir()): - - if exists('hostpython'): - info('hostpython already exists, skipping build') - self.ctx.hostpython = join(self.get_build_dir(), - 'hostpython') - self.ctx.hostpgen = join(self.get_build_dir(), - 'hostpgen') - return - - if 'LIBS' in os.environ: - os.environ.pop('LIBS') - configure = sh.Command('./configure') - - # shprint(configure) - shprint(configure, '--enable-unicode=ucs4') - shprint(sh.make, '-j5') - - shprint(sh.mv, join('Parser', 'pgen'), 'hostpgen') - - if exists('python.exe'): - shprint(sh.mv, 'python.exe', 'hostpython') - elif exists('python'): - shprint(sh.mv, 'python', 'hostpython') - else: - warning('Unable to find the python executable after ' - 'hostpython build! Exiting.') - exit(1) - - self.ctx.hostpython = join(self.get_build_dir(), 'hostpython') - self.ctx.hostpgen = join(self.get_build_dir(), 'hostpgen') - - -recipe = Hostpython2Recipe() diff --git a/src/python-for-android/recipes/libsecp256k1/__init__.py b/src/python-for-android/recipes/libsecp256k1/__init__.py deleted file mode 100644 index 69349d9..0000000 --- a/src/python-for-android/recipes/libsecp256k1/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -from pythonforandroid.toolchain import shprint, current_directory -from pythonforandroid.recipe import Recipe -from multiprocessing import cpu_count -from os.path import exists -import sh - - -class LibSecp256k1Recipe(Recipe): - - version = 'b0452e6' - url = 'https://github.com/bitcoin-core/secp256k1/archive/{version}.zip' - - def build_arch(self, arch): - super(LibSecp256k1Recipe, self).build_arch(arch) - env = self.get_recipe_env(arch) - with current_directory(self.get_build_dir(arch.arch)): - if not exists('configure'): - shprint(sh.Command('./autogen.sh'), _env=env) - shprint( - sh.Command('./configure'), - '--host=' + arch.toolchain_prefix, - '--prefix=' + self.ctx.get_python_install_dir(), - '--enable-shared', - '--enable-module-recovery', - '--enable-experimental', - '--enable-module-ecdh', - _env=env) - shprint(sh.make, '-j' + str(cpu_count()), _env=env) - libs = ['.libs/libsecp256k1.so'] - self.install_libs(arch, *libs) - - -recipe = LibSecp256k1Recipe() diff --git a/src/python-for-android/recipes/scrypt/__init__.py b/src/python-for-android/recipes/scrypt/__init__.py deleted file mode 100644 index 17a4ef5..0000000 --- a/src/python-for-android/recipes/scrypt/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -import os -from pythonforandroid.recipe import CythonRecipe - - -class ScryptRecipe(CythonRecipe): - - version = '0.8.6' - url = 'https://bitbucket.org/mhallin/py-scrypt/get/v{version}.zip' - depends = [('python2', 'python3crystax'), 'setuptools', 'openssl'] - call_hostpython_via_targetpython = False - patches = ["remove_librt.patch"] - - def get_recipe_env(self, arch, with_flags_in_cc=True): - """ - Adds openssl recipe to include and library path. - """ - env = super(ScryptRecipe, self).get_recipe_env(arch, with_flags_in_cc) - openssl_build_dir = self.get_recipe( - 'openssl', self.ctx).get_build_dir(arch.arch) - env['CFLAGS'] += ' -I{}'.format(os.path.join(openssl_build_dir, 'include')) - env['LDFLAGS'] += ' -L{}'.format( - self.ctx.get_libs_dir(arch.arch) + - '-L{}'.format(self.ctx.libs_dir)) + ' -L{}'.format( - openssl_build_dir) - # required additional library and path for Crystax - if self.ctx.ndk == 'crystax': - # only keeps major.minor (discards patch) - python_version = self.ctx.python_recipe.version[0:3] - ndk_dir_python = os.path.join(self.ctx.ndk_dir, 'sources/python/', python_version) - env['LDFLAGS'] += ' -L{}'.format(os.path.join(ndk_dir_python, 'libs', arch.arch)) - env['LDFLAGS'] += ' -lpython{}m'.format(python_version) - # until `pythonforandroid/archs.py` gets merged upstream: - # https://github.com/kivy/python-for-android/pull/1250/files#diff-569e13021e33ced8b54385f55b49cbe6 - env['CFLAGS'] += ' -I{}/include/python/'.format(ndk_dir_python) - return env - - -recipe = ScryptRecipe() diff --git a/src/python-for-android/recipes/scrypt/remove_librt.patch b/src/python-for-android/recipes/scrypt/remove_librt.patch deleted file mode 100644 index 270bab2..0000000 --- a/src/python-for-android/recipes/scrypt/remove_librt.patch +++ /dev/null @@ -1,20 +0,0 @@ ---- a/setup.py 2018-05-06 23:25:08.757522119 +0200 -+++ b/setup.py 2018-05-06 23:25:30.269797365 +0200 -@@ -15,7 +15,6 @@ - - if sys.platform.startswith('linux'): - define_macros = [('HAVE_CLOCK_GETTIME', '1'), -- ('HAVE_LIBRT', '1'), - ('HAVE_POSIX_MEMALIGN', '1'), - ('HAVE_STRUCT_SYSINFO', '1'), - ('HAVE_STRUCT_SYSINFO_MEM_UNIT', '1'), -@@ -23,8 +22,7 @@ - ('HAVE_SYSINFO', '1'), - ('HAVE_SYS_SYSINFO_H', '1'), - ('_FILE_OFFSET_BITS', '64')] -- libraries = ['crypto', 'rt'] -- includes = ['/usr/local/include', '/usr/include'] -+ libraries = ['crypto'] - CFLAGS.append('-O2') - elif sys.platform.startswith('win32'): - define_macros = [('inline', '__inline')] diff --git a/src/pywalib.py b/src/pywalib.py index c1527d8..bb83221 100755 --- a/src/pywalib.py +++ b/src/pywalib.py @@ -3,22 +3,21 @@ from __future__ import print_function, unicode_literals import os -import shutil +from enum import Enum from os.path import expanduser import requests -import rlp -from devp2p.app import BaseApp -from ethereum.tools.keys import PBKDF2_CONSTANTS -from ethereum.transactions import Transaction -from ethereum.utils import denoms, normalize_address -from pyethapp.accounts import Account, AccountsService +from eth_utils import to_checksum_address +from web3 import HTTPProvider, Web3 + +from ethereum_utils import AccountUtils ETHERSCAN_API_KEY = None ROUND_DIGITS = 3 KEYSTORE_DIR_PREFIX = expanduser("~") # default pyethapp keystore path KEYSTORE_DIR_SUFFIX = ".config/pyethapp/keystore/" +DEFAULT_GAS_PRICE_GWEI = 4 class UnknownEtherscanException(Exception): @@ -33,14 +32,45 @@ class NoTransactionFoundException(UnknownEtherscanException): pass -class PyWalib(object): +class ChainID(Enum): + MAINNET = 1 + MORDEN = 2 + ROPSTEN = 3 + + +class HTTPProviderFactory: + + PROVIDER_URLS = { + # ChainID.MAINNET: 'https://api.myetherapi.com/eth', + ChainID.MAINNET: 'https://mainnet.infura.io', + # ChainID.ROPSTEN: 'https://api.myetherapi.com/rop', + ChainID.ROPSTEN: 'https://ropsten.infura.io', + } + + @classmethod + def create(cls, chain_id=ChainID.MAINNET) -> HTTPProvider: + url = cls.PROVIDER_URLS[chain_id] + return HTTPProvider(url) + - def __init__(self, keystore_dir=None): +def get_etherscan_prefix(chain_id=ChainID.MAINNET) -> str: + PREFIXES = { + ChainID.MAINNET: 'https://api.etherscan.io/api', + ChainID.ROPSTEN: 'https://api-ropsten.etherscan.io/api', + } + return PREFIXES[chain_id] + + +class PyWalib: + + def __init__(self, keystore_dir=None, chain_id=ChainID.MAINNET): if keystore_dir is None: keystore_dir = PyWalib.get_default_keystore_path() - self.app = BaseApp( - config=dict(accounts=dict(keystore_dir=keystore_dir))) - AccountsService.register_with_app(self.app) + self.keystore_dir = keystore_dir + self.account_utils = AccountUtils(keystore_dir=self.keystore_dir) + self.chain_id = chain_id + self.provider = HTTPProviderFactory.create(self.chain_id) + self.web3 = Web3(self.provider) @staticmethod def handle_etherscan_error(response_json): @@ -57,22 +87,13 @@ def handle_etherscan_error(response_json): assert message == "OK" @staticmethod - def address_hex(address): - """ - Normalizes address. - """ - prefix = "0x" - address_hex = prefix + normalize_address(address).encode("hex") - return address_hex - - @staticmethod - def get_balance(address): + def get_balance(address, chain_id=ChainID.MAINNET): """ Retrieves the balance from etherscan.io. The balance is returned in ETH rounded to the second decimal. """ - address = PyWalib.address_hex(address) - url = 'https://api.etherscan.io/api' + address = to_checksum_address(address) + url = get_etherscan_prefix(chain_id) url += '?module=account&action=balance' url += '&address=%s' % address url += '&tag=latest' @@ -87,13 +108,23 @@ def get_balance(address): balance_eth = round(balance_eth, ROUND_DIGITS) return balance_eth + def get_balance_web3(self, address): + """ + The balance is returned in ETH rounded to the second decimal. + """ + address = to_checksum_address(address) + balance_wei = self.web3.eth.getBalance(address) + balance_eth = balance_wei / float(pow(10, 18)) + balance_eth = round(balance_eth, ROUND_DIGITS) + return balance_eth + @staticmethod - def get_transaction_history(address): + def get_transaction_history(address, chain_id=ChainID.MAINNET): """ Retrieves the transaction history from etherscan.io. """ - address = PyWalib.address_hex(address) - url = 'https://api.etherscan.io/api' + address = to_checksum_address(address) + url = get_etherscan_prefix(chain_id) url += '?module=account&action=txlist' url += '&sort=asc' url += '&address=%s' % address @@ -108,12 +139,12 @@ def get_transaction_history(address): value_wei = int(transaction['value']) value_eth = value_wei / float(pow(10, 18)) value_eth = round(value_eth, ROUND_DIGITS) - from_address = PyWalib.address_hex(transaction['from']) + from_address = to_checksum_address(transaction['from']) to_address = transaction['to'] # on contract creation, "to" is replaced by the "contractAddress" if not to_address: to_address = transaction['contractAddress'] - to_address = PyWalib.address_hex(to_address) + to_address = to_checksum_address(to_address) sent = from_address == address received = not sent extra_dict = { @@ -129,109 +160,71 @@ def get_transaction_history(address): return transactions @staticmethod - def get_out_transaction_history(address): + def get_out_transaction_history(address, chain_id=ChainID.MAINNET): """ Retrieves the outbound transaction history from Etherscan. """ - transactions = PyWalib.get_transaction_history(address) + transactions = PyWalib.get_transaction_history(address, chain_id) out_transactions = [] for transaction in transactions: if transaction['extra_dict']['sent']: out_transactions.append(transaction) return out_transactions + # TODO: can be removed since the migration to web3 @staticmethod - def get_nonce(address): + def get_nonce(address, chain_id=ChainID.MAINNET): """ Gets the nonce by counting the list of outbound transactions from Etherscan. """ try: - out_transactions = PyWalib.get_out_transaction_history(address) + out_transactions = PyWalib.get_out_transaction_history( + address, chain_id) except NoTransactionFoundException: out_transactions = [] nonce = len(out_transactions) return nonce @staticmethod - def handle_etherscan_tx_error(response_json): + def handle_web3_exception(exception: ValueError): """ - Raises an exception on unexpected response. + Raises the appropriated typed exception on web3 ValueError exception. """ - error = response_json.get("error") - if error is not None: - code = error.get("code") - if code in [-32000, -32010]: - raise InsufficientFundsException() - else: - raise UnknownEtherscanException(response_json) + error = exception.args[0] + code = error.get("code") + if code in [-32000, -32010]: + raise InsufficientFundsException(error) + else: + raise UnknownEtherscanException(error) - @staticmethod - def add_transaction(tx): - """ - POST transaction to etherscan.io. - """ - tx_hex = rlp.encode(tx).encode("hex") - # use https://etherscan.io/pushTx to debug - print("tx_hex:", tx_hex) - url = 'https://api.etherscan.io/api' - url += '?module=proxy&action=eth_sendRawTransaction' - if ETHERSCAN_API_KEY: - '&apikey=%' % ETHERSCAN_API_KEY - # TODO: handle 504 timeout, 403 and other errors from etherscan - response = requests.post(url, data={'hex': tx_hex}) - # response is like: - # {'jsonrpc': '2.0', 'result': '0x24a8...14ea', 'id': 1} - # or on error like this: - # {'jsonrpc': '2.0', 'id': 1, 'error': { - # 'message': 'Insufficient funds...', 'code': -32010, 'data': None}} - response_json = response.json() - print("response_json:", response_json) - PyWalib.handle_etherscan_tx_error(response_json) - tx_hash = response_json['result'] - # the response differs from the other responses + def transact(self, to, value=0, data='', sender=None, gas=25000, + gasprice=DEFAULT_GAS_PRICE_GWEI * (10 ** 9)): + """ + Signs and broadcasts a transaction. + Returns transaction hash. + """ + address = sender or self.get_main_account().address + from_address_normalized = to_checksum_address(address) + nonce = self.web3.eth.getTransactionCount(from_address_normalized) + transaction = { + 'chainId': self.chain_id.value, + 'gas': gas, + 'gasPrice': gasprice, + 'nonce': nonce, + 'value': value, + } + account = self.account_utils.get_by_address(address) + private_key = account.privkey + signed_tx = self.web3.eth.account.signTransaction( + transaction, private_key) + try: + tx_hash = self.web3.eth.sendRawTransaction( + signed_tx.rawTransaction) + except ValueError as e: + self.handle_web3_exception(e) return tx_hash - def transact(self, to, value=0, data='', sender=None, startgas=25000, - gasprice=60 * denoms.shannon): - """ - Inspired from pyethapp/console_service.py except that we use - Etherscan for retrieving the nonce as we as for broadcasting the - transaction. - Arg value is in Wei. - """ - # account.unlock(password) - sender = normalize_address(sender or self.get_main_account().address) - to = normalize_address(to, allow_blank=True) - nonce = PyWalib.get_nonce(sender) - # creates the transaction - tx = Transaction(nonce, gasprice, startgas, to, value, data) - # then signs it - self.app.services.accounts.sign_tx(sender, tx) - assert tx.sender == sender - PyWalib.add_transaction(tx) - return tx - - @staticmethod - def new_account_helper(password, security_ratio=None): - """ - Helper method for creating an account in memory. - Returns the created account. - security_ratio is a ratio of the default PBKDF2 iterations. - Ranging from 1 to 100 means 100% of the iterations. - """ - # TODO: perform validation on security_ratio (within allowed range) - if security_ratio: - default_iterations = PBKDF2_CONSTANTS["c"] - new_iterations = int((default_iterations * security_ratio) / 100) - PBKDF2_CONSTANTS["c"] = new_iterations - uuid = None - account = Account.new(password, uuid=uuid) - # reverts to previous iterations - if security_ratio: - PBKDF2_CONSTANTS["c"] = default_iterations - return account - @staticmethod def deleted_account_dir(keystore_dir): """ @@ -252,55 +245,32 @@ def deleted_account_dir(keystore_dir): deleted_keystore_dir_name) return deleted_keystore_dir + # TODO: update docstring + # TODO: update security_ratio def new_account(self, password, security_ratio=None): """ Creates an account on the disk and returns it. security_ratio is a ratio of the default PBKDF2 iterations. Ranging from 1 to 100 means 100% of the iterations. """ - account = PyWalib.new_account_helper(password, security_ratio) - app = self.app - account.path = os.path.join( - app.services.accounts.keystore_dir, account.address.encode('hex')) - self.app.services.accounts.add_account(account) + account = self.account_utils.new_account(password=password) return account def delete_account(self, account): """ Deletes the given `account` from the `keystore_dir` directory. - Then deletes it from the `AccountsService` account manager instance. In fact, moves it to another location; another directory at the same level. """ - app = self.app - keystore_dir = app.services.accounts.keystore_dir - deleted_keystore_dir = PyWalib.deleted_account_dir(keystore_dir) - # create the deleted account dir if required - if not os.path.exists(deleted_keystore_dir): - os.makedirs(deleted_keystore_dir) - # "removes" it from the file system - account_filename = os.path.basename(account.path) - deleted_account_path = os.path.join( - deleted_keystore_dir, account_filename) - shutil.move(account.path, deleted_account_path) - # deletes it from the `AccountsService` account manager instance - account_service = self.get_account_list() - account_service.accounts.remove(account) + self.account_utils.delete_account(account) def update_account_password( self, account, new_password, current_password=None): """ The current_password is optional if the account is already unlocked. """ - if current_password is not None: - account.unlock(current_password) - # make sure the PBKDF2 param stays the same - default_iterations = PBKDF2_CONSTANTS["c"] - account_iterations = account.keystore["crypto"]["kdfparams"]["c"] - PBKDF2_CONSTANTS["c"] = account_iterations - self.app.services.accounts.update_account(account, new_password) - # reverts to previous iterations - PBKDF2_CONSTANTS["c"] = default_iterations + self.account_utils.update_account_password( + account, new_password, current_password) @staticmethod def get_default_keystore_path(): @@ -315,7 +285,7 @@ def get_account_list(self): """ Returns the Account list. """ - accounts = self.app.services.accounts + accounts = self.account_utils.get_account_list() return accounts def get_main_account(self): diff --git a/src/pywallet/aliasform.py b/src/pywallet/aliasform.py index 766d8f7..61213c6 100644 --- a/src/pywallet/aliasform.py +++ b/src/pywallet/aliasform.py @@ -20,7 +20,7 @@ def __init__(self, account, **kwargs): # circular ref from pywallet.controller import Controller super(AliasForm, self).__init__(**kwargs) - self.address = "0x" + account.address.encode("hex") + self.address = "0x" + account.address.hex() try: self.alias = Controller.get_address_alias(self.address) except KeyError: diff --git a/src/pywallet/controller.py b/src/pywallet/controller.py index 7ab8e77..69990f9 100644 --- a/src/pywallet/controller.py +++ b/src/pywallet/controller.py @@ -6,7 +6,6 @@ from kivy.core.window import Window from kivy.logger import Logger from kivy.properties import DictProperty, ObjectProperty -from kivy.storage.jsonstore import JsonStore from kivy.uix.floatlayout import FloatLayout from kivy.utils import platform from kivymd.bottomsheet import MDListBottomSheet @@ -18,8 +17,13 @@ from pywallet.flashqrcode import FlashQrCodeScreen from pywallet.managekeystore import ManageKeystoreScreen from pywallet.overview import OverviewScreen +from pywallet.settings import Settings +from pywallet.settings_screen import SettingsScreen +from pywallet.store import Store from pywallet.switchaccount import SwitchAccountScreen -from pywallet.utils import Dialog, load_kv_from_py, run_in_thread +from pywallet.utils import (Dialog, check_request_write_permission, + check_write_permission, load_kv_from_py, + run_in_thread) # Time before loading the next screen. # The idea is to let the application render before trying to add child widget, @@ -40,9 +44,8 @@ class Controller(FloatLayout): accounts_history = DictProperty({}) def __init__(self, **kwargs): - super(Controller, self).__init__(**kwargs) - keystore_path = Controller.get_keystore_path() - self.pywalib = PyWalib(keystore_path) + super().__init__(**kwargs) + self._pywalib = None self.screen_history = [] self.register_event_type('on_alias_updated') Clock.schedule_once(lambda dt: self.load_landing_page()) @@ -135,6 +138,21 @@ def toolbar(self): def screen_manager(self): return self.ids.screen_manager_id + @property + def pywalib(self): + """ + Gets or creates the PyWalib object. + Also recreates the object if the keystore_path changed. + """ + keystore_path = Settings.get_keystore_path() + chain_id = Settings.get_stored_network() + if self._pywalib is None or \ + self._pywalib.keystore_dir != keystore_path or \ + self._pywalib.chain_id != chain_id: + self._pywalib = PyWalib( + keystore_dir=keystore_path, chain_id=chain_id) + return self._pywalib + def set_toolbar_title(self, title): self.toolbar.title_property = title @@ -156,6 +174,7 @@ def screen_manager_current(self, current, direction=None, history=True): 'switch_account': SwitchAccountScreen, 'manage_keystores': ManageKeystoreScreen, 'flashqrcode': FlashQrCodeScreen, + 'settings_screen': SettingsScreen, 'about': AboutScreen, } screen_manager = self.screen_manager @@ -197,42 +216,13 @@ def patch_keystore_path(): # uses kivy user_data_dir (/sdcard/) pywalib.KEYSTORE_DIR_PREFIX = App.get_running_app().user_data_dir - @classmethod - def get_keystore_path(cls): - """ - This is the Kivy default keystore path. - """ - keystore_path = os.environ.get('KEYSTORE_PATH') - if keystore_path is None: - Controller.patch_keystore_path() - keystore_path = PyWalib.get_default_keystore_path() - return keystore_path - - @staticmethod - def get_store_path(): - """ - Returns the full user store path. - """ - user_data_dir = App.get_running_app().user_data_dir - store_path = os.path.join(user_data_dir, 'store.json') - return store_path - - @classmethod - def get_store(cls): - """ - Returns the full user Store object instance. - """ - store_path = cls.get_store_path() - store = JsonStore(store_path) - return store - @classmethod def delete_account_alias(cls, account): """ Deletes the alias for the given account. """ - address = "0x" + account.address.encode("hex") - store = cls.get_store() + address = "0x" + account.address.hex() + store = Store.get_store() alias_dict = store['alias'] alias_dict.pop(address) store['alias'] = alias_dict @@ -251,8 +241,8 @@ def set_account_alias(cls, account, alias): except KeyError: pass return - address = "0x" + account.address.encode("hex") - store = cls.get_store() + address = "0x" + account.address.hex() + store = Store.get_store() try: alias_dict = store['alias'] except KeyError: @@ -267,7 +257,7 @@ def get_address_alias(cls, address): """ Returns the alias of the given address string. """ - store = cls.get_store() + store = Store.get_store() return store.get('alias')[address] @classmethod @@ -275,7 +265,7 @@ def get_account_alias(cls, account): """ Returns the alias of the given Account object. """ - address = "0x" + account.address.encode("hex") + address = "0x" + account.address.hex() return cls.get_address_alias(address) @staticmethod @@ -287,7 +277,7 @@ def src_dir(): def update_toolbar_title_balance(self, instance=None, value=None): if self.current_account is None: return - address = '0x' + self.current_account.address.encode("hex") + address = '0x' + self.current_account.address.hex() try: balance = self.accounts_balance[address] except KeyError: @@ -295,9 +285,34 @@ def update_toolbar_title_balance(self, instance=None, value=None): title = "%s ETH" % (balance) self.set_toolbar_title(title) - def load_landing_page(self): + def show_storage_permissions_required_dialog(self): + title = "External storage permissions required" + body = "" + body += "In order to save your keystore, PyWallet requires access " + body += "to your device storage. " + body += "Please allow PyWallet to access it when prompted." + dialog = Dialog.create_dialog(title, body) + dialog.open() + return dialog + + def check_external_storage_permission(self, callback): """ - Loads the landing page. + Checks for external storage permissions and pops a dialog to ask for it + if needed. + Returns True if the permission was already granted, otherwise prompts + for permissions dialog (async) and returns False. + """ + if check_write_permission(): + return True + dialog = self.show_storage_permissions_required_dialog() + dialog.bind( + on_dismiss=lambda *x: check_request_write_permission( + callback)) + return False + + def try_load_current_account(self): + """ + Load the main account or fallback to the create account screen. """ try: # will trigger account data fetching @@ -311,6 +326,29 @@ def load_landing_page(self): except IndexError: self.load_create_new_account() + def load_landing_page(self): + """ + Loads the landing page. + """ + @mainthread + def on_permissions_callback(permissions, grant_results): + """ + On write permission callback, toggles loading account from + persistent keystore if granted. + Also loads the current account to the app. + This is called from the Java thread, hence the `@mainthread`. + Find out more on the p4a permissions callback in: + https://github.com/kivy/python-for-android/pull/1818 + """ + if all(grant_results): + Settings.set_is_persistent_keystore(True) + self.try_load_current_account() + # if no permission yet, the try_load_current_account() call will be + # async from the callback + if self.check_external_storage_permission( + callback=on_permissions_callback): + self.try_load_current_account() + @run_in_thread def fetch_balance(self): """ @@ -318,9 +356,10 @@ def fetch_balance(self): """ if self.current_account is None: return - address = '0x' + self.current_account.address.encode("hex") + address = '0x' + self.current_account.address.hex() + chain_id = Settings.get_stored_network() try: - balance = PyWalib.get_balance(address) + balance = PyWalib.get_balance(address, chain_id) except ConnectionError: Dialog.on_balance_connection_error() Logger.warning('ConnectionError', exc_info=True) @@ -355,7 +394,7 @@ def copy_address_clipboard(self): Copies the current account address to the clipboard. """ account = self.current_account - address = "0x" + account.address.encode("hex") + address = "0x" + account.address.hex() Clipboard.copy(address) def prompt_alias_dialog(self): @@ -450,6 +489,19 @@ def load_flash_qr_code(self): # loads the flash QR Code screen self.screen_manager_current('flashqrcode', direction='left') + def load_settings_screen(self): + """ + Loads the settings screen. + """ + if SCREEN_SWITCH_DELAY: + Clock.schedule_once( + lambda dt: self.screen_manager_current( + 'settings_screen', direction='left'), + SCREEN_SWITCH_DELAY) + else: + self.screen_manager_current( + 'settings_screen', direction='left') + def load_about_screen(self): """ Loads the about screen. diff --git a/src/pywallet/flashqrcode.kv b/src/pywallet/flashqrcode.kv index 0b149be..cc16644 100644 --- a/src/pywallet/flashqrcode.kv +++ b/src/pywallet/flashqrcode.kv @@ -1,4 +1,5 @@ #:import platform kivy.utils.platform +#:import ZBarSymbol pyzbar.pyzbar.ZBarSymbol : @@ -10,3 +11,4 @@ self.unbind_on_symbols() ZBarCam: id: zbarcam_id + code_types: ZBarSymbol.QRCODE, diff --git a/src/pywallet/flashqrcode.py b/src/pywallet/flashqrcode.py index f2bfbbf..65eff1b 100644 --- a/src/pywallet/flashqrcode.py +++ b/src/pywallet/flashqrcode.py @@ -22,24 +22,9 @@ class FlashQrCodeScreen(Screen): - def __init__(self, **kwargs): - super(FlashQrCodeScreen, self).__init__(**kwargs) - self.setup() - - def setup(self): - """ - Configures scanner to handle only QRCodes. - """ - self.controller = App.get_running_app().controller - self.zbarcam = self.ids.zbarcam_id - # loads ZBarCam only when needed, refs: - # https://github.com/AndreMiras/PyWallet/issues/94 - import zbar - # enables QRCode scanning only - self.zbarcam.scanner.set_config( - zbar.Symbol.NONE, zbar.Config.ENABLE, 0) - self.zbarcam.scanner.set_config( - zbar.Symbol.QRCODE, zbar.Config.ENABLE, 1) + @property + def zbarcam(self): + return self.ids.zbarcam_id def bind_on_symbols(self): """ @@ -62,6 +47,7 @@ def on_symbols(self, instance, symbols): return symbol = symbols[0] # update Send screen address - self.controller.send.send_to_address = symbol.data + controller = App.get_running_app().controller + controller.send.send_to_address = symbol.data.decode('utf-8') self.zbarcam.play = False - self.controller.load_landing_page() + controller.load_landing_page() diff --git a/src/pywallet/history.py b/src/pywallet/history.py index 495d2dc..58d009d 100644 --- a/src/pywallet/history.py +++ b/src/pywallet/history.py @@ -8,6 +8,7 @@ from pywalib import NoTransactionFoundException, PyWalib from pywallet.list import IconLeftWidget +from pywallet.settings import Settings from pywallet.utils import Dialog, load_kv_from_py, run_in_thread load_kv_from_py(__file__) @@ -74,7 +75,7 @@ def update_history_list(self, instance=None, value=None): """ if self.current_account is None: return - address = '0x' + self.current_account.address.encode("hex") + address = '0x' + self.current_account.address.hex() try: transactions = self.controller.accounts_history[address] except KeyError: @@ -91,9 +92,10 @@ def update_history_list(self, instance=None, value=None): def fetch_history(self): if self.current_account is None: return - address = '0x' + self.current_account.address.encode("hex") + chain_id = Settings.get_stored_network() + address = '0x' + self.current_account.address.hex() try: - transactions = PyWalib.get_transaction_history(address) + transactions = PyWalib.get_transaction_history(address, chain_id) except ConnectionError: Dialog.on_history_connection_error() Logger.warning('ConnectionError', exc_info=True) diff --git a/src/pywallet/managekeystore.py b/src/pywallet/managekeystore.py index d9a4444..6c2a0dd 100644 --- a/src/pywallet/managekeystore.py +++ b/src/pywallet/managekeystore.py @@ -4,6 +4,7 @@ from kivy.uix.boxlayout import BoxLayout from kivy.uix.screenmanager import Screen +from pywallet.settings import Settings from pywallet.utils import Dialog, load_kv_from_py, run_in_thread load_kv_from_py(__file__) @@ -141,7 +142,7 @@ def on_current_account(self, instance, account): # Controller.current_account to None if account is None: return - address = "0x" + account.address.encode("hex") + address = "0x" + account.address.hex() self.address_property = address @@ -287,7 +288,7 @@ def __init__(self, **kwargs): def setup(self): self.controller = App.get_running_app().controller - self.keystore_path = self.controller.get_keystore_path() + self.keystore_path = Settings.get_keystore_path() accounts = self.controller.pywalib.get_account_list() if len(accounts) == 0: title = "No keystore found." diff --git a/src/pywallet/navigation.kv b/src/pywallet/navigation.kv index 8dcec69..49a9040 100644 --- a/src/pywallet/navigation.kv +++ b/src/pywallet/navigation.kv @@ -13,6 +13,10 @@ icon: "key" text: "Account" on_release: app.controller.load_manage_keystores() + NavigationDrawerIconButton: + icon: "settings" + text: "Settings" + on_release: app.controller.load_settings_screen() NavigationDrawerIconButton: icon: "help" text: "About" diff --git a/src/pywallet/navigation.py b/src/pywallet/navigation.py index 2b722e0..c3cf1ac 100644 --- a/src/pywallet/navigation.py +++ b/src/pywallet/navigation.py @@ -7,7 +7,6 @@ from pywallet.utils import load_kv_from_py - load_kv_from_py(__file__) @@ -33,7 +32,7 @@ def on_current_account(self, account): # Controller.current_account to None if account is None: return - address = "0x" + account.address.encode("hex") + address = "0x" + account.address.hex() self.address_property = address def _update_specific_text_color(self, instance, value): diff --git a/src/pywallet/overview.kv b/src/pywallet/overview.kv index 383b013..1a3d5eb 100644 --- a/src/pywallet/overview.kv +++ b/src/pywallet/overview.kv @@ -7,7 +7,7 @@ : orientation: 'vertical' AddressButton: - address_property: root.current_account_string + address_property: root.current_account_string or 'No account selected' on_release: app.controller.open_address_options() History: id: history_id diff --git a/src/pywallet/overview.py b/src/pywallet/overview.py index d6a4467..b832575 100644 --- a/src/pywallet/overview.py +++ b/src/pywallet/overview.py @@ -43,7 +43,7 @@ def update_current_account_string(self): if self.current_account is None: return account = self.current_account - address = "0x" + account.address.encode("hex") + address = "0x" + account.address.hex() self.current_account_string = address def on_current_account(self, instance, account): diff --git a/src/pywallet/receive.py b/src/pywallet/receive.py index 3d59312..27acf31 100644 --- a/src/pywallet/receive.py +++ b/src/pywallet/receive.py @@ -41,7 +41,7 @@ def update_address_property(self): Updates address_property from current_account. """ account = self.current_account - address = "0x" + account.address.encode("hex") + address = "0x" + account.address.hex() self.address_property = address def on_current_account(self, instance, account): diff --git a/src/pywallet/scrollablelabel.py b/src/pywallet/scrollablelabel.py index da023bb..bf67c33 100644 --- a/src/pywallet/scrollablelabel.py +++ b/src/pywallet/scrollablelabel.py @@ -3,7 +3,6 @@ from pywallet.utils import load_kv_from_py - load_kv_from_py(__file__) diff --git a/src/pywallet/send.kv b/src/pywallet/send.kv index c5e6af9..c705401 100644 --- a/src/pywallet/send.kv +++ b/src/pywallet/send.kv @@ -23,12 +23,10 @@ required: True write_tab: False text: str(round(root.send_amount, ROUND_DIGITS)) - on_text: root.send_amount = args[1] + on_text: root.on_send_amount_text(*args) AnchorLayout: MDRaisedButton: id: send_button_id text: "Send" on_release: root.on_send_click() PushUp: - - diff --git a/src/pywallet/send.py b/src/pywallet/send.py index c3fa60c..8f6b10c 100644 --- a/src/pywallet/send.py +++ b/src/pywallet/send.py @@ -1,4 +1,4 @@ -from ethereum.utils import normalize_address +from eth_utils import to_checksum_address from kivy.app import App from kivy.logger import Logger from kivy.properties import NumericProperty, StringProperty @@ -7,6 +7,7 @@ from pywalib import (ROUND_DIGITS, InsufficientFundsException, UnknownEtherscanException) from pywallet.passwordform import PasswordForm +from pywallet.settings import Settings from pywallet.utils import Dialog, load_kv_from_py, run_in_thread load_kv_from_py(__file__) @@ -18,15 +19,12 @@ class Send(BoxLayout): send_to_address = StringProperty("") send_amount = NumericProperty(0) - def __init__(self, **kwargs): - super(Send, self).__init__(**kwargs) - def verify_to_address_field(self): title = "Input error" body = "Invalid address field" try: - normalize_address(self.send_to_address) - except Exception: + to_checksum_address(self.send_to_address) + except ValueError: dialog = Dialog.create_dialog(title, body) dialog.open() return False @@ -69,6 +67,13 @@ def prompt_password_dialog(self): dialog, content.password)) return dialog + def on_send_amount_text(self, instance, value): + try: + self.send_amount = float(value) + except ValueError: + # e.g. value is empty, refs #152 + pass + def on_send_click(self): if not self.verify_fields(): Dialog.show_invalid_form_dialog() @@ -84,11 +89,13 @@ def unlock_send_transaction(self): """ controller = App.get_running_app().controller pywalib = controller.pywalib - address = normalize_address(self.send_to_address) + address = to_checksum_address(self.send_to_address) amount_eth = round(self.send_amount, ROUND_DIGITS) amount_wei = int(amount_eth * pow(10, 18)) + gas_price_gwei = Settings.get_stored_gas_price() + gas_price_wei = int(gas_price_gwei * (10 ** 9)) # TODO: not the main account, but the current account - account = controller.pywalib.get_main_account() + account = controller.current_account Dialog.snackbar_message("Unlocking account...") try: account.unlock(self.password) @@ -99,7 +106,9 @@ def unlock_send_transaction(self): Dialog.snackbar_message("Unlocked! Sending transaction...") sender = account.address try: - pywalib.transact(address, value=amount_wei, data='', sender=sender) + pywalib.transact( + address, value=amount_wei, data='', sender=sender, + gasprice=gas_price_wei) except InsufficientFundsException: Dialog.snackbar_message("Insufficient funds") return diff --git a/src/pywallet/settings.py b/src/pywallet/settings.py new file mode 100644 index 0000000..c10f07a --- /dev/null +++ b/src/pywallet/settings.py @@ -0,0 +1,135 @@ +import os + +from kivy.app import App +from kivy.utils import platform + +from pywalib import DEFAULT_GAS_PRICE_GWEI, KEYSTORE_DIR_SUFFIX, ChainID +from pywallet.store import Store + +NETWORK_SETTINGS = 'network' +GAS_PRICE_SETTINGS = 'gas_price' +PERSIST_KEYSTORE_SETTINGS = 'persist_keystore' + + +class Settings: + """ + Screen for configuring network, gas price... + """ + + @classmethod + def get_stored_network(cls): + """ + Retrieves last stored network value, defaults to Mainnet. + """ + store = Store.get_store() + try: + network_dict = store[NETWORK_SETTINGS] + except KeyError: + network_dict = {} + network_name = network_dict.get( + 'value', ChainID.MAINNET.name) + network = ChainID[network_name] + return network + + @classmethod + def set_stored_network(cls, network: ChainID): + """ + Persists network settings. + """ + store = Store.get_store() + store.put(NETWORK_SETTINGS, value=network.name) + + @classmethod + def is_stored_mainnet(cls): + network = cls.get_stored_network() + return network == ChainID.MAINNET + + @classmethod + def is_stored_testnet(cls): + network = cls.get_stored_network() + return network == ChainID.ROPSTEN + + @classmethod + def get_stored_gas_price(cls): + """ + Retrieves stored gas price value, defaults to DEFAULT_GAS_PRICE_GWEI. + """ + store = Store.get_store() + try: + gas_price_dict = store[GAS_PRICE_SETTINGS] + except KeyError: + gas_price_dict = {} + gas_price = gas_price_dict.get( + 'value', DEFAULT_GAS_PRICE_GWEI) + return gas_price + + @classmethod + def set_stored_gas_price(cls, gas_price: int): + """ + Persists gas price settings. + """ + store = Store.get_store() + store.put(GAS_PRICE_SETTINGS, value=gas_price) + + @classmethod + def is_persistent_keystore(cls): + """ + Retrieves the settings value regarding the keystore persistency. + Defaults to False. + """ + store = Store.get_store() + try: + persist_keystore_dict = store[PERSIST_KEYSTORE_SETTINGS] + except KeyError: + persist_keystore_dict = {} + persist_keystore = persist_keystore_dict.get( + 'value', False) + return persist_keystore + + @classmethod + def set_is_persistent_keystore(cls, persist_keystore: bool): + """ + Saves keystore persistency settings. + """ + store = Store.get_store() + store.put(PERSIST_KEYSTORE_SETTINGS, value=persist_keystore) + + @staticmethod + def get_persistent_keystore_path(): + app = App.get_running_app() + # TODO: hardcoded path, refs: + # https://github.com/AndreMiras/EtherollApp/issues/145 + return os.path.join('/sdcard', app.name) + + @staticmethod + def get_non_persistent_keystore_path(): + app = App.get_running_app() + return app.user_data_dir + + @classmethod + def _get_android_keystore_prefix(cls): + """ + Returns the Android keystore path prefix. + The location differs based on the persistency user settings. + """ + if cls.is_persistent_keystore(): + keystore_dir_prefix = cls.get_persistent_keystore_path() + else: + keystore_dir_prefix = cls.get_non_persistent_keystore_path() + return keystore_dir_prefix + + @classmethod + def get_keystore_path(cls): + """ + Returns the keystore directory path. + This can be overriden by the `KEYSTORE_PATH` environment variable. + """ + keystore_path = os.environ.get('KEYSTORE_PATH') + if keystore_path is not None: + return keystore_path + KEYSTORE_DIR_PREFIX = os.path.expanduser("~") + if platform == "android": + KEYSTORE_DIR_PREFIX = cls._get_android_keystore_prefix() + keystore_path = os.path.join( + KEYSTORE_DIR_PREFIX, KEYSTORE_DIR_SUFFIX) + return keystore_path diff --git a/src/pywallet/settings_screen.kv b/src/pywallet/settings_screen.kv new file mode 100644 index 0000000..531c96e --- /dev/null +++ b/src/pywallet/settings_screen.kv @@ -0,0 +1,84 @@ +#:import MDCheckbox kivymd.selectioncontrols.MDCheckbox +#:import MDSlider kivymd.slider.MDSlider + + +: + on_pre_enter: + root.load_settings() + on_pre_leave: + root.store_settings() + name: 'settings_screen' + BoxLayoutMarginLayout: + BoxLayoutAddMargin: + orientation: 'vertical' + margin: 30, 10, 30, 10 + MDLabel: + text: 'Network' + font_style: 'Title' + theme_text_color: 'Primary' + BoxLayout: + orientation: 'horizontal' + MDCheckbox: + id: mainnet_checkbox_id + group: 'network' + size_hint: None, None + size: dp(48), dp(48) + pos_hint: {'center_x': 0.5, 'center_y': 0.5} + active: root.is_stored_mainnet + MDLabel: + text: 'Mainnet' + theme_text_color: 'Primary' + BoxLayout: + orientation: 'horizontal' + MDCheckbox: + id: testnet_checkbox_id + group: 'network' + size_hint: None, None + size: dp(48), dp(48) + pos_hint: {'center_x': 0.5, 'center_y': 0.5} + active: root.is_stored_testnet + MDLabel: + text: 'Testnet' + theme_text_color: 'Primary' + PushUp: + BoxLayout: + orientation: 'vertical' + MDLabel: + text: 'Gas price (gwei)' + font_style: 'Title' + theme_text_color: 'Primary' + BoxLayout: + orientation: 'horizontal' + MDLabel: + id: gas_price_label_id + text: "{}".format(int(gas_price_slider_id.value)) + font_size: dp(20) + width: dp(40) + size_hint_x: None + theme_text_color: 'Primary' + MDSlider: + id: gas_price_slider_id + range: 0, 50 + value: root.stored_gas_price + step: 1 + PushUp: + BoxLayout: + orientation: 'vertical' + MDLabel: + text: 'Persist keystore' + font_style: 'Title' + theme_text_color: 'Primary' + BoxLayout: + orientation: 'horizontal' + MDSwitch: + id: persist_keystore_switch_id + size_hint: None, None + size: dp(36), dp(48) + pos_hint: {'center_x': 0.75, 'center_y': 0.5} + on_active: root.check_request_write_permission() + MDLabel: + halign: 'right' + text: 'Keeps accounts even if the app is uninstalled' + theme_text_color: 'Primary' + + PushUp: diff --git a/src/pywallet/settings_screen.py b/src/pywallet/settings_screen.py new file mode 100644 index 0000000..464a5ef --- /dev/null +++ b/src/pywallet/settings_screen.py @@ -0,0 +1,156 @@ +import os +import shutil + +from kivy.properties import BooleanProperty, NumericProperty +from kivy.uix.screenmanager import Screen + +from pywalib import KEYSTORE_DIR_SUFFIX, ChainID +from pywallet.settings import Settings +from pywallet.utils import (check_request_write_permission, + check_write_permission, load_kv_from_py) + +load_kv_from_py(__file__) + + +class SettingsScreen(Screen): + """ + Screen for configuring network, gas price... + """ + + is_stored_mainnet = BooleanProperty() + is_stored_testnet = BooleanProperty() + stored_gas_price = NumericProperty() + + def store_network(self): + """ + Saves selected network to the store. + """ + network = self.get_ui_network() + Settings.set_stored_network(network) + + def store_gas_price(self): + """ + Saves gas price value to the store. + """ + gas_price = self.get_ui_gas_price() + Settings.set_stored_gas_price(gas_price) + + def store_is_persistent_keystore(self): + """ + Saves the persistency option to the store. + Note that to save `True` we also check if we have write permissions. + """ + persist_keystore = self.is_ui_persistent_keystore() + persist_keystore = persist_keystore and check_write_permission() + persistency_toggled = ( + Settings.is_persistent_keystore() != persist_keystore) + if persistency_toggled: + self.sync_keystore(persist_keystore) + Settings.set_is_persistent_keystore(persist_keystore) + + def sync_to_directory(source_dir, destination_dir): + """ + Copy source dir content to the destination dir one. + Files already existing get overriden. + """ + os.makedirs(destination_dir, exist_ok=True) + files = os.listdir(source_dir) + for f in files: + source_file = os.path.join(source_dir, f) + # file path is given rather than the dir so it gets overriden + destination_file = os.path.join(destination_dir, f) + try: + shutil.copy(source_file, destination_file) + except PermissionError: + # `copymode()` may have fail, fallback to simple copy + shutil.copyfile(source_file, destination_file) + + @classmethod + def sync_keystore_to_persistent(cls): + """ + Copies keystore from non persistent to persistent storage. + """ + # TODO: handle dir doesn't exist + source_dir = os.path.join( + Settings.get_non_persistent_keystore_path(), + KEYSTORE_DIR_SUFFIX) + destination_dir = os.path.join( + Settings.get_persistent_keystore_path(), + KEYSTORE_DIR_SUFFIX) + cls.sync_to_directory(source_dir, destination_dir) + + @classmethod + def sync_keystore_to_non_persistent(cls): + """ + Copies keystore from persistent to non persistent storage. + """ + # TODO: handle dir doesn't exist + source_dir = os.path.join( + Settings.get_persistent_keystore_path(), + KEYSTORE_DIR_SUFFIX) + destination_dir = os.path.join( + Settings.get_non_persistent_keystore_path(), + KEYSTORE_DIR_SUFFIX) + cls.sync_to_directory(source_dir, destination_dir) + + @classmethod + def sync_keystore(cls, to_persistent): + if to_persistent: + cls.sync_keystore_to_persistent() + else: + cls.sync_keystore_to_non_persistent() + + def set_persist_keystore_switch_state(self, active): + """ + The MDSwitch UI look doesn't seem to be binded to its status. + Here the UI look will be updated depending on the "active" status. + """ + mdswitch = self.ids.persist_keystore_switch_id + if self.is_ui_persistent_keystore() != active: + mdswitch.ids.thumb.trigger_action() + + def load_settings(self): + """ + Load json store settings to UI properties. + """ + self.is_stored_mainnet = Settings.is_stored_mainnet() + self.is_stored_testnet = Settings.is_stored_testnet() + self.stored_gas_price = Settings.get_stored_gas_price() + is_persistent_keystore = ( + Settings.is_persistent_keystore() and check_write_permission()) + self.set_persist_keystore_switch_state(is_persistent_keystore) + + def store_settings(self): + """ + Stores settings to json store. + """ + self.store_gas_price() + self.store_network() + self.store_is_persistent_keystore() + + def get_ui_network(self): + """ + Retrieves network values from UI. + """ + if self.is_ui_mainnet(): + network = ChainID.MAINNET + else: + network = ChainID.ROPSTEN + return network + + def is_ui_mainnet(self): + return self.ids.mainnet_checkbox_id.active + + def is_ui_testnet(self): + return self.ids.testnet_checkbox_id.active + + def get_ui_gas_price(self): + return self.ids.gas_price_slider_id.value + + def is_ui_persistent_keystore(self): + return self.ids.persist_keystore_switch_id.active + + def check_request_write_permission(self): + # previous state before the toggle + if self.is_ui_persistent_keystore(): + check_request_write_permission() diff --git a/src/pywallet/store.py b/src/pywallet/store.py new file mode 100644 index 0000000..3ffd505 --- /dev/null +++ b/src/pywallet/store.py @@ -0,0 +1,27 @@ +import os + +from kivy.app import App +from kivy.storage.jsonstore import JsonStore + + +class Store: + + @classmethod + def get_store_path(cls): + """ + Returns the full user store path. + On Android, the store is purposely not stored on the sdcard. + That way we don't need permission for handling user settings. + Also losing it is not critical. + """ + app = App.get_running_app() + return os.path.join(app.user_data_dir, 'store.json') + + @classmethod + def get_store(cls): + """ + Returns user Store object. + """ + store_path = cls.get_store_path() + store = JsonStore(store_path) + return store diff --git a/src/pywallet/switchaccount.py b/src/pywallet/switchaccount.py index 3189b6f..80bb387 100644 --- a/src/pywallet/switchaccount.py +++ b/src/pywallet/switchaccount.py @@ -30,7 +30,7 @@ def create_item(self, account): """ # circular ref from pywallet.controller import Controller - address = "0x" + account.address.encode("hex") + address = "0x" + account.address.hex() # gets the alias if exists try: text = Controller.get_address_alias(address) diff --git a/src/pywallet/utils.py b/src/pywallet/utils.py index 51466bb..d6218cb 100644 --- a/src/pywallet/utils.py +++ b/src/pywallet/utils.py @@ -7,9 +7,12 @@ from kivy.clock import mainthread from kivy.lang import Builder from kivy.metrics import dp +from kivy.uix.boxlayout import BoxLayout +from kivy.utils import platform from kivymd.dialog import MDDialog from kivymd.label import MDLabel from kivymd.snackbar import Snackbar +from layoutmargin import AddMargin, MarginLayout def run_in_thread(fn): @@ -50,6 +53,29 @@ def load_kv_from_py(f): ) +def check_write_permission(): + """ + Android runtime storage permission check. + """ + if platform != "android": + return True + from android.permissions import Permission, check_permission + permission = Permission.WRITE_EXTERNAL_STORAGE + return check_permission(permission) + + +def check_request_write_permission(callback=None): + """ + Android runtime storage permission check & request. + """ + had_permission = check_write_permission() + if not had_permission: + from android.permissions import Permission, request_permissions + permissions = [Permission.WRITE_EXTERNAL_STORAGE] + request_permissions(permissions, callback) + return had_permission + + class StringIOCBWrite(StringIO): """ Inherits StringIO, provides callback on write. @@ -68,11 +94,9 @@ def write(self, s): Calls the StringIO.write() method then the callback_write with given string parameter. """ - # io.StringIO expects unicode - s_unicode = s.decode('utf-8') - super(StringIOCBWrite, self).write(s_unicode) + super(StringIOCBWrite, self).write(s) if self.callback_write is not None: - self.callback_write(s_unicode) + self.callback_write(s) class Dialog(object): @@ -197,3 +221,11 @@ def on_history_value_error(cls): body = "Couldn't not decode history data." dialog = cls.create_dialog(title, body) dialog.open() + + +class BoxLayoutMarginLayout(MarginLayout, BoxLayout): + pass + + +class BoxLayoutAddMargin(AddMargin, BoxLayout): + pass diff --git a/src/tests/fixtures/one_qr_code.png b/src/tests/fixtures/one_qr_code.png new file mode 100644 index 0000000..4d9fc31 Binary files /dev/null and b/src/tests/fixtures/one_qr_code.png differ diff --git a/src/tests/pywallet/test_settings.py b/src/tests/pywallet/test_settings.py new file mode 100644 index 0000000..1523060 --- /dev/null +++ b/src/tests/pywallet/test_settings.py @@ -0,0 +1,91 @@ +import shutil +import unittest +from tempfile import mkdtemp +from unittest import mock + +from kivy.app import App + +from main import PyWalletApp +from pywalib import ChainID +from pywallet.settings import Settings + + +class TestSettings(unittest.TestCase): + """ + Unit tests Settings methods. + """ + @classmethod + def setUpClass(cls): + """ + Makes sure the `App.get_running_app` singleton gets created. + """ + PyWalletApp() + + def setUp(self): + """ + Creates a temporary user data dir for storing the user config. + """ + self.temp_path = mkdtemp(prefix='pywallet') + self.app = App.get_running_app() + self.app._user_data_dir = self.temp_path + + def tearDown(self): + """ + Deletes temporary user data dir. + """ + shutil.rmtree(self.temp_path, ignore_errors=True) + + def test_get_set_stored_network(self): + """ + Checks default stored network and set method. + """ + # checks default + assert Settings.get_stored_network() == ChainID.MAINNET + # checks set + Settings.set_stored_network(ChainID.ROPSTEN) + assert Settings.get_stored_network() == ChainID.ROPSTEN + + def test_is_stored_mainnet(self): + Settings.set_stored_network(ChainID.MAINNET) + assert Settings.is_stored_mainnet() is True + Settings.set_stored_network(ChainID.ROPSTEN) + assert Settings.is_stored_mainnet() is False + + def test_is_stored_testnet(self): + Settings.set_stored_network(ChainID.MAINNET) + assert Settings.is_stored_testnet() is False + Settings.set_stored_network(ChainID.ROPSTEN) + assert Settings.is_stored_testnet() is True + + def test_get_set_stored_gas_price(self): + """ + Checks default stored gas price and set method. + """ + # checks default + assert Settings.get_stored_gas_price() == 4 + # checks set + Settings.set_stored_gas_price(42) + assert Settings.get_stored_gas_price() == 42 + + def test_get_set_is_persistent_keystore(self): + """ + Checks default persist value and set method. + """ + # checks default + assert Settings.is_persistent_keystore() is False + # checks set + Settings.set_is_persistent_keystore(True) + assert Settings.is_persistent_keystore() is True + + def test_get_android_keystore_prefix(self): + """ + The keystore prefix should be the same as user_data_dir by default. + But it can also be persisted to the sdcard. + """ + assert Settings.is_persistent_keystore() is False + prefix = Settings._get_android_keystore_prefix() + assert prefix == self.app.user_data_dir + with mock.patch.object( + Settings, 'is_persistent_keystore', return_value=True): + prefix = Settings._get_android_keystore_prefix() + assert prefix == '/sdcard/pywallet' diff --git a/src/tests/test_ethereum_utils.py b/src/tests/test_ethereum_utils.py new file mode 100644 index 0000000..146d33c --- /dev/null +++ b/src/tests/test_ethereum_utils.py @@ -0,0 +1,164 @@ +import os +import shutil +import unittest +from tempfile import TemporaryDirectory, mkdtemp +from unittest import mock + +from ethereum_utils import AccountUtils +from pyethapp_accounts import Account + +PASSWORD = "password" + + +class TestAccountUtils(unittest.TestCase): + + def setUp(self): + self.keystore_dir = mkdtemp() + self.account_utils = AccountUtils(self.keystore_dir) + + def tearDown(self): + shutil.rmtree(self.keystore_dir, ignore_errors=True) + + def test_new_account(self): + """ + Simple account creation test case. + 1) verifies the current account list is empty + 2) creates a new account and verify we can retrieve it + 3) tries to unlock the account + """ + # 1) verifies the current account list is empty + self.assertEqual(self.account_utils.get_account_list(), []) + # 2) creates a new account and verify we can retrieve it + password = PASSWORD + account = self.account_utils.new_account(password, iterations=1) + self.assertEqual(len(self.account_utils.get_account_list()), 1) + self.assertEqual(account, self.account_utils.get_account_list()[0]) + # 3) tries to unlock the account + # it's unlocked by default after creation + self.assertFalse(account.locked) + # let's lock it and unlock it back + account.lock() + self.assertTrue(account.locked) + account.unlock(password) + self.assertFalse(account.locked) + + def test_get_account_list(self): + """ + Makes sure get_account_list() loads properly accounts from file system. + """ + password = PASSWORD + self.assertEqual(self.account_utils.get_account_list(), []) + account = self.account_utils.new_account(password, iterations=1) + self.assertEqual(len(self.account_utils.get_account_list()), 1) + account = self.account_utils.get_account_list()[0] + self.assertIsNotNone(account.path) + # removes the cache copy and checks again if it gets loaded + self.account_utils._accounts = None + self.assertEqual(len(self.account_utils.get_account_list()), 1) + account = self.account_utils.get_account_list()[0] + self.assertIsNotNone(account.path) + + def test_get_account_list_error(self): + """ + get_account_list() should not cache empty account on PermissionError. + """ + # creates a temporary account that we'll try to list + password = PASSWORD + account = Account.new(password, uuid=None, iterations=1) + account.path = os.path.join(self.keystore_dir, account.address.hex()) + with open(account.path, 'w') as f: + f.write(account.dump()) + # `listdir()` can raise a `PermissionError` + with mock.patch('os.listdir') as mock_listdir: + mock_listdir.side_effect = PermissionError + with self.assertRaises(PermissionError): + self.account_utils.get_account_list() + # the empty account list should not be catched and loading it again + # should show the existing account on file system + self.assertEqual(len(self.account_utils.get_account_list()), 1) + self.assertEqual( + self.account_utils.get_account_list()[0].address, account.address) + + def test_get_account_list_no_dir(self): + """ + The keystore directory should be created if it doesn't exist, refs: + https://github.com/AndreMiras/PyWallet/issues/133 + """ + # nominal case when the directory already exists + with TemporaryDirectory() as keystore_dir: + self.assertTrue(os.path.isdir(keystore_dir)) + account_utils = AccountUtils(keystore_dir) + self.assertEqual(account_utils.get_account_list(), []) + # when the directory doesn't exist it should also be created + self.assertFalse(os.path.isdir(keystore_dir)) + account_utils = AccountUtils(keystore_dir) + self.assertTrue(os.path.isdir(keystore_dir)) + self.assertEqual(account_utils.get_account_list(), []) + shutil.rmtree(keystore_dir, ignore_errors=True) + + def test_deleted_account_dir(self): + """ + The deleted_account_dir() helper method should be working + with and without trailing slash. + """ + expected_deleted_keystore_dir = '/tmp/keystore-deleted' + keystore_dirs = [ + # without trailing slash + '/tmp/keystore', + # with one trailing slash + '/tmp/keystore/', + # with two trailing slashes + '/tmp/keystore//', + ] + for keystore_dir in keystore_dirs: + self.assertEqual( + AccountUtils.deleted_account_dir(keystore_dir), + expected_deleted_keystore_dir) + + def test_delete_account(self): + """ + Creates a new account and delete it. + Then verify we can load the account from the backup/trash location. + """ + password = PASSWORD + account = self.account_utils.new_account(password, iterations=1) + address = account.address + self.assertEqual(len(self.account_utils.get_account_list()), 1) + # deletes the account and verifies it's not loaded anymore + self.account_utils.delete_account(account) + self.assertEqual(len(self.account_utils.get_account_list()), 0) + # even recreating the AccountUtils object + self.account_utils = AccountUtils(self.keystore_dir) + self.assertEqual(len(self.account_utils.get_account_list()), 0) + # tries to reload it from the backup/trash location + deleted_keystore_dir = AccountUtils.deleted_account_dir( + self.keystore_dir) + self.account_utils = AccountUtils(deleted_keystore_dir) + self.assertEqual(len(self.account_utils.get_account_list()), 1) + self.assertEqual( + self.account_utils.get_account_list()[0].address, address) + + def test_delete_account_already_exists(self): + """ + If the destination (backup/trash) directory where the account is moved + already exists, it should be handled gracefully. + This could happens if the account gets deleted, then reimported and + deleted again, refs: + https://github.com/AndreMiras/PyWallet/issues/88 + """ + password = PASSWORD + account = self.account_utils.new_account(password, iterations=1) + # creates a file in the backup/trash folder that would conflict + # with the deleted account + deleted_keystore_dir = AccountUtils.deleted_account_dir( + self.keystore_dir) + os.makedirs(deleted_keystore_dir) + account_filename = os.path.basename(account.path) + deleted_account_path = os.path.join( + deleted_keystore_dir, account_filename) + # create that file + open(deleted_account_path, 'a').close() + # then deletes the account and verifies it worked + self.assertEqual(len(self.account_utils.get_account_list()), 1) + self.account_utils.delete_account(account) + self.assertEqual(len(self.account_utils.get_account_list()), 0) diff --git a/src/tests/test_import.py b/src/tests/test_import.py index 26c99ae..08bcb04 100644 --- a/src/tests/test_import.py +++ b/src/tests/test_import.py @@ -6,45 +6,9 @@ class ModulesImportTestCase(unittest.TestCase): Simple test cases, verifying core modules were installed properly. """ - def test_pyethash(self): - import pyethash - self.assertIsNotNone(pyethash.get_seedhash(0)) - def test_hashlib_sha3(self): import hashlib - import sha3 self.assertIsNotNone(hashlib.sha3_512()) - self.assertIsNotNone(sha3.keccak_512()) - - def test_scrypt(self): - import scrypt - # This will take at least 0.1 seconds - data = scrypt.encrypt('a secret message', 'password', maxtime=0.1) - self.assertIsNotNone(data) - # 'scrypt\x00\r\x00\x00\x00\x08\x00\x00\x00\x01RX9H' - decrypted = scrypt.decrypt(data, 'password', maxtime=0.5) - self.assertEqual(decrypted, 'a secret message') - - def test_pyethereum(self): - from ethereum import compress, utils - self.assertIsNotNone(compress) - self.assertIsNotNone(utils) - - def test_pyethapp(self): - from pyethapp.accounts import Account - from ethereum.tools.keys import PBKDF2_CONSTANTS - # backup iterations - iterations_backup = PBKDF2_CONSTANTS['c'] - # speeds up the test - PBKDF2_CONSTANTS['c'] = 100 - password = "foobar" - uuid = None - account = Account.new(password, uuid=uuid) - # restore iterations - PBKDF2_CONSTANTS['c'] = iterations_backup - address = account.address.encode('hex') - self.assertIsNotNone(account) - self.assertIsNotNone(address) def test_zbarcam(self): from zbarcam import zbarcam diff --git a/src/tests/test_pywalib.py b/src/tests/test_pywalib.py index ef9486c..ae235c6 100644 --- a/src/tests/test_pywalib.py +++ b/src/tests/test_pywalib.py @@ -2,6 +2,7 @@ import shutil import unittest from tempfile import mkdtemp +from unittest import mock from pywalib import (InsufficientFundsException, NoTransactionFoundException, PyWalib, UnknownEtherscanException) @@ -168,7 +169,7 @@ def test_handle_etherscan_error(self): } with self.assertRaises(UnknownEtherscanException) as e: PyWalib.handle_etherscan_error(response_json) - self.assertEqual(e.exception.message, response_json) + self.assertEqual(e.exception.args[0], response_json) # no error response_json = { 'message': 'OK', 'result': [], 'status': '1' @@ -177,34 +178,22 @@ def test_handle_etherscan_error(self): PyWalib.handle_etherscan_error(response_json), None) - def test_address_hex(self): + def test_get_balance(self): """ - Checks handle_etherscan_error() error handling. + Checks get_balance() returns a float. """ - expected_addresss = ADDRESS - # no 0x prefix - address_no_prefix = ADDRESS.lower().strip("0x") - address = address_no_prefix - normalized = PyWalib.address_hex(address) - self.assertEqual(normalized, expected_addresss) - # uppercase - address = "0x" + address_no_prefix.upper() - normalized = PyWalib.address_hex(address) - self.assertEqual(normalized, expected_addresss) - # prefix cannot be uppercase - address = "0X" + address_no_prefix.upper() - with self.assertRaises(Exception) as context: - PyWalib.address_hex(address) - self.assertEqual( - context.exception.message, - "Invalid address format: '%s'" % (address)) + pywalib = self.pywalib + address = ADDRESS + balance_eth = pywalib.get_balance(address) + self.assertTrue(type(balance_eth), float) - def test_get_balance(self): + def test_get_balance_web3(self): """ Checks get_balance() returns a float. """ + pywalib = self.pywalib address = ADDRESS - balance_eth = PyWalib.get_balance(address) + balance_eth = pywalib.get_balance_web3(address) self.assertTrue(type(balance_eth), float) def helper_get_history(self, transactions): @@ -293,39 +282,73 @@ def test_get_nonce_no_transaction(self): nonce = PyWalib.get_nonce(address) self.assertEqual(nonce, 0) - def test_handle_etherscan_tx_error(self): + def test_handle_web3_exception(self): + """ + Checks handle_web3_exception() error handling. + """ + # insufficient funds + exception = ValueError({ + 'code': -32000, + 'message': 'insufficient funds for gas * price + value' + }) + with self.assertRaises(InsufficientFundsException) as e: + PyWalib.handle_web3_exception(exception) + self.assertEqual(e.exception.args[0], exception.args[0]) + # unknown error code + exception = ValueError({ + 'code': 0, + 'message': 'Unknown error' + }) + with self.assertRaises(UnknownEtherscanException) as e: + PyWalib.handle_web3_exception(exception) + self.assertEqual(e.exception.args[0], exception.args[0]) + # no code + exception = ValueError({ + 'message': 'Unknown error' + }) + with self.assertRaises(UnknownEtherscanException) as e: + PyWalib.handle_web3_exception(exception) + self.assertEqual(e.exception.args[0], exception.args[0]) + + def test_transact(self): """ - Checks handle_etherscan_tx_error() error handling. + Basic transact() test, makes sure web3 sendRawTransaction gets called. """ - # no transaction found - response_json = { - 'jsonrpc': '2.0', 'id': 1, 'error': { - 'message': - 'Insufficient funds. ' - 'The account you tried to send transaction from does not ' - 'have enough funds. Required 10001500000000000000 and' - 'got: 53856999715015294.', - 'code': -32010, 'data': None - } - } - with self.assertRaises(InsufficientFundsException): - PyWalib.handle_etherscan_tx_error(response_json) - # unknown error - response_json = { - 'jsonrpc': '2.0', 'id': 1, 'error': { - 'message': - 'Unknown error', - 'code': 0, 'data': None - } + pywalib = self.pywalib + account = self.helper_new_account() + to = ADDRESS + sender = account.address + value_wei = 100 + with mock.patch('web3.eth.Eth.sendRawTransaction') \ + as m_sendRawTransaction: + pywalib.transact(to=to, value=value_wei, sender=sender) + self.assertTrue(m_sendRawTransaction.called) + + def test_transact_no_sender(self): + """ + The sender parameter should default to the main account. + Makes sure the transaction is being signed by the available account. + """ + pywalib = self.pywalib + account = self.helper_new_account() + to = ADDRESS + value_wei = 100 + with mock.patch('web3.eth.Eth.sendRawTransaction') \ + as m_sendRawTransaction, \ + mock.patch('web3.eth.Eth.account.signTransaction') \ + as m_signTransaction: + pywalib.transact(to=to, value=value_wei) + self.assertTrue(m_sendRawTransaction.called) + m_signTransaction.call_args_list + transaction = { + 'chainId': 1, + 'gas': 25000, + 'gasPrice': 4000000000, + 'nonce': 0, + 'value': value_wei, } - with self.assertRaises(UnknownEtherscanException) as e: - PyWalib.handle_etherscan_tx_error(response_json) - self.assertEqual(e.exception.message, response_json) - # no error - response_json = {'jsonrpc': '2.0', 'id': 1} - self.assertEqual( - PyWalib.handle_etherscan_tx_error(response_json), - None) + expected_call = mock.call(transaction, account.privkey) + self.assertEqual(m_signTransaction.call_args_list, [expected_call]) def test_transact_no_funds(self): """ diff --git a/src/tests/ui/test_ui_base.py b/src/tests/ui/test_ui_base.py index 4f5269d..637d46e 100644 --- a/src/tests/ui/test_ui_base.py +++ b/src/tests/ui/test_ui_base.py @@ -8,11 +8,12 @@ import unittest from functools import partial from tempfile import mkdtemp +from unittest import mock import kivymd -import mock import requests from kivy.clock import Clock +from kivy.core.image import Image import main import pywalib @@ -20,6 +21,14 @@ from pywallet.utils import Dialog ADDRESS = "0xab5801a7d398351b8be11c439e05c5b3259aec9b" +FIXTURE_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), '..', 'fixtures') + + +def patch_get_store_path(temp_path): + store_path = os.path.join(temp_path, 'store.json') + return mock.patch( + 'pywallet.controller.Store.get_store_path', lambda: store_path) class Test(unittest.TestCase): @@ -127,7 +136,7 @@ def helper_test_create_first_account(self, app): create_account_thread = threading.enumerate()[1] self.assertEqual(type(create_account_thread), threading.Thread) self.assertEqual( - create_account_thread._Thread__target.func_name, + create_account_thread._target.__name__, "create_account") # waits for the end of the thread create_account_thread.join() @@ -214,7 +223,7 @@ def helper_test_create_account_form(self, app): self.assertEqual( type(create_account_thread), threading.Thread) self.assertEqual( - create_account_thread._Thread__target.func_name, + create_account_thread._target.__name__, "create_account") # waits for the end of the thread create_account_thread.join() @@ -232,8 +241,8 @@ def helper_test_create_account_form(self, app): def helper_test_on_send_click(self, app): """ - This is a regression test for #63, verify clicking "Send" Ethers works - as expected, refs #63. + Verifies clicking "Send" Ethers works as expected, refs #63. + Also checks for the amount field, refs #152. """ controller = app.controller # TODO: use dispatch('on_release') on navigation drawer @@ -249,6 +258,14 @@ def helper_test_on_send_click(self, app): self.assertEqual(dialogs[1].title, 'Invalid form') Dialog.dismiss_all_dialogs() self.assertEqual(len(dialogs), 0) + # also checks for the amount field, refs #152 + send_amount_id = send.ids.send_amount_id + send_amount_id.text = '0.1' + # the send_amount property should get updated from the input + self.assertEqual(send.send_amount, 0.1) + # blank amount shouldn't crash the app, just get ignored + send_amount_id.text = '' + self.assertEqual(send.send_amount, 0.1) def helper_test_send(self, app): """ @@ -284,8 +301,9 @@ def helper_test_send(self, app): # unlock_send_transaction() thread should be running self.assertEqual(len(threading.enumerate()), 2) thread = threading.enumerate()[1] + self.assertEqual(type(thread), threading.Thread) self.assertEqual( - thread._Thread__target.func_name, 'unlock_send_transaction') + thread._target.__name__, 'unlock_send_transaction') thread.join() # checks snackbar messages self.assertEqual( @@ -322,19 +340,11 @@ def helper_test_address_alias(self, app): account1 = pywalib.get_account_list()[0] # creates a second account account2 = pywalib.new_account(password="password", security_ratio=1) - address1 = '0x' + account1.address.encode("hex") - address2 = '0x' + account2.address.encode("hex") + address1 = '0x' + account1.address.hex() + address2 = '0x' + account2.address.hex() Controller = main.Controller - @staticmethod - def get_store_path(): - """ - Makes sure we don't mess up with actual store config file. - """ - os.environ['KEYSTORE_PATH'] = self.temp_path - store_path = os.path.join(self.temp_path, 'store.json') - return store_path - with mock.patch.object(Controller, 'get_store_path', get_store_path): + with patch_get_store_path(self.temp_path): # no alias by default with self.assertRaises(KeyError): Controller.get_account_alias(account1) @@ -399,7 +409,7 @@ def helper_test_delete_account(self, app): manage_existing = controller.manage_existing account_address_id = manage_existing.ids.account_address_id account = pywalib.get_account_list()[0] - account_address = '0x' + account.address.encode("hex") + account_address = '0x' + account.address.hex() self.assertEqual(account_address_id.text, account_address) # clicks delete delete_button_id = manage_existing.ids.delete_button_id @@ -546,12 +556,13 @@ def helper_test_controller_fetch_balance(self, app): account = controller.current_account balance = 42 # 1) simple case, library PyWalib.get_balance() gets called - with mock.patch('pywalib.PyWalib.get_balance') as mock_get_balance: + with mock.patch('pywalib.PyWalib.get_balance') as mock_get_balance, \ + patch_get_store_path(self.temp_path): mock_get_balance.return_value = balance thread = controller.fetch_balance() thread.join() - address = '0x' + account.address.encode("hex") - mock_get_balance.assert_called_with(address) + address = '0x' + account.address.hex() + mock_get_balance.assert_called_with(address, pywalib.ChainID.MAINNET) # and the balance updated self.assertEqual( controller.accounts_balance[address], balance) @@ -647,6 +658,24 @@ def helper_test_about(self, app): self.assertEqual( str(controller.about.__class__), "") + def helper_test_flashqrcode(self, app): + """ + Verifies the flash QRCode screen loads and can flash codes. + """ + controller = app.controller + controller.load_flash_qr_code() + self.advance_frames(1) + screen_manager = controller.screen_manager + self.assertEqual(screen_manager.current, 'flashqrcode') + flashqrcode_screen = screen_manager.get_screen('flashqrcode') + zbarcam = flashqrcode_screen.ids.zbarcam_id + fixture_path = os.path.join(FIXTURE_DIR, 'one_qr_code.png') + texture = Image(fixture_path).texture + camera = mock.Mock(texture=texture) + zbarcam._on_texture(camera) + send = controller.send + self.assertEqual(send.send_to_address, 'zbarlight test qr code') + # main test function def run_test(self, app, *args): Clock.schedule_interval(self.pause, 0.000001) @@ -665,6 +694,7 @@ def run_test(self, app, *args): self.helper_test_controller_fetch_balance(app) self.helper_test_delete_last_account(app) self.helper_test_about(app) + self.helper_test_flashqrcode(app) # Comment out if you are editing the test, it'll leave the # Window opened. app.stop() diff --git a/src/version.py b/src/version.py index 57202aa..2402883 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -__version__ = '2018.0729' +__version__ = '2019.0812' diff --git a/tox.ini b/tox.ini index ec177ff..9778b39 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = pep8,isort-check,py27 +envlist = pep8,isort-check,py36 # no setup.py to be ran skipsdist = True # trick to enable pre-installation of Cython @@ -26,6 +26,6 @@ commands = flake8 src/ exclude = src/python-for-android/ [testenv:isort-check] -basepython = python2 +basepython = python3 commands = - isort --check-only --recursive src/ + isort --check-only --recursive --diff src/