diff --git a/blacklist.txt b/blacklist.txt index f059284..f8a5302 100644 --- a/blacklist.txt +++ b/blacklist.txt @@ -1,5 +1,4 @@ lib-dynload/_csv.so -distutils/* xml/* pdb.{doc,pyo} profile.{doc,pyo} diff --git a/buildozer.spec b/buildozer.spec index ae208f9..da8e62f 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -38,70 +38,46 @@ version.filename = %(source.dir)s/version.py # (list) Application requirements # comma seperated e.g. requirements = sqlite3,kivy requirements = - hostpython3crystax==3.6, - python3crystax==3.6, - setuptools, - kivy==1.10.1, - plyer==1.3.1, - oscpy==0.3.0, android, - gevent, + attrdict==2.0.0, + certifi==2018.10.15, cffi, - https://github.com/AndreMiras/KivyMD/archive/9b2206a.tar.gz, - openssl, - pyelliptic==1.5.7, - asn1crypto==0.24.0, - coincurve==7.1.0, - bitcoin==1.1.42, - devp2p==0.9.3, - pycryptodome==3.4.6, - pbkdf2==1.3, - py-ecc==1.4.2, - pysha3==1.0.2, - pyyaml==3.12, - scrypt==0.8.6, - ethereum==2.1.1, - ptyprocess==0.5.2, - pexpect==4.4.0, - Pygments==2.2.0, - decorator==4.2.1, - ipython-genutils==0.2.0, - traitlets==4.3.2, - ipython==5.5.0, - click==6.7, - pickleshare==0.7.4, - simplegeneric==0.8.1, - wcwidth==0.1.7, - prompt-toolkit==1.0.15, - https://github.com/ethereum/pyethapp/archive/8406f32.zip, - idna==2.6, - typing==3.6.4, - eth-keys==0.2.0b3, + chardet==3.0.4, + cytoolz==0.9.0, + eth-abi==1.2.2, + eth-account==0.3.0, + eth-hash==0.2.0, eth-keyfile==0.5.1, - rlp==0.6.0, + eth-keys==0.2.0b3, eth-rlp==0.1.2, - attrdict==2.0.0, - eth-account==0.2.2, + eth-typing==2.0.0, + eth-utils==1.2.1, + gevent, hexbytes==0.1.0, + hostpython3crystax==3.6, + https://github.com/AndreMiras/garden.layoutmargin/archive/20180517.tar.gz, + https://github.com/AndreMiras/KivyMD/archive/69f3e88.tar.gz, + https://github.com/AndreMiras/pyetheroll/archive/884805b.tar.gz, + https://github.com/corpetty/py-etherscan-api/archive/cb91fb3.tar.gz, + idna==2.7, + kivy==1.10.1, lru-dict==1.1.5, - web3==4.0.0b11, - certifi==2018.1.18, - chardet==3.0.4, - urllib3==1.22, - requests==2.18.4, - https://github.com/corpetty/py-etherscan-api/archive/cb91fb3.zip, - eth-testrpc==1.3.3, - eth-hash==0.1.1, - pyethash==0.1.27, - cytoolz==0.9.0, - toolz==0.9.0, - eth-abi==1.0.0, - eth-utils==1.0.1, - raven==6.6.0, + openssl, + oscpy==0.3.0, + parsimonious==0.8.1, + plyer==1.3.1, + pycryptodome==3.4.6, + Pygments==2.2.0, + python3crystax==3.6, + qrcode==6.0, + raven==6.9.0, + requests==2.20.0, requests-cache==0.4.13, - qrcode, - https://github.com/AndreMiras/garden.layoutmargin/archive/20180517.zip, - https://github.com/AndreMiras/pyetheroll/archive/20181031.zip + rlp==1.0.3, + setuptools, + 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 diff --git a/requirements.txt b/requirements.txt index 1fc2df9..7162c49 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,16 @@ -web3==4.0.0b11 -eth-testrpc==1.3.3 -https://github.com/corpetty/py-etherscan-api/archive/cb91fb3.zip#egg=py-etherscan-api +eth-utils==1.2.1 flake8 +future +https://github.com/AndreMiras/garden.layoutmargin/archive/20180517.tar.gz#egg=layoutmargin +https://github.com/AndreMiras/KivyMD/archive/69f3e88.tar.gz#egg=kivymd +https://github.com/AndreMiras/pyetheroll/archive/884805b.tar.gz#egg=pyetheroll +https://github.com/corpetty/py-etherscan-api/archive/cb91fb3.tar.gz#egg=py-etherscan-api isort -Kivy==1.10.1 -https://github.com/AndreMiras/KivyMD/archive/9b2206a.tar.gz#egg=kivymd -ethereum==2.1.1 -# 2017/10/30 develop -https://github.com/ethereum/pyethapp/archive/8406f32.zip#egg=pyethapp -raven==6.6.0 -requests-cache +kivy==1.10.1 kivyunittest==0.1.8 -qrcode==5.3 -eth-rlp==0.1.2 -rlp==0.6.0 -# compatible with libcrypto.so.1.1 -# https://github.com/golemfactory/golem-messages/pull/112/files -https://github.com/mfranciszkiewicz/pyelliptic/archive/1.5.10.tar.gz#egg=pyelliptic -https://github.com/AndreMiras/garden.layoutmargin/archive/20180517.zip#egg=layoutmargin -plyer==1.3.1 oscpy==0.3.0 -https://github.com/AndreMiras/pyetheroll/archive/20181031.zip#egg=pyetheroll +plyer==1.3.1 +qrcode==6.0 +raven==6.9.0 +requests-cache +web3==4.8.1 diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 041b541..25e4238 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,9 +1,10 @@ # Change Log -## [v20181028] +## [Unreleased] - Split dedicated Etheroll library, refs #97 + - Remove legacy dependencies, refs #112 ## [v20181028] diff --git a/src/distutils/README.md b/src/distutils/README.md new file mode 100644 index 0000000..523061c --- /dev/null +++ b/src/distutils/README.md @@ -0,0 +1,3 @@ +# README + +This is being embedded in the repository because python3crystax seems to blacklist it. diff --git a/src/distutils/__init__.py b/src/distutils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/distutils/version.py b/src/distutils/version.py new file mode 100644 index 0000000..4984fad --- /dev/null +++ b/src/distutils/version.py @@ -0,0 +1,348 @@ +# flake8: noqa +# CrystaX seems to be blacklisting `distutils` module somehow. +# Since LooseVersion is needed by web3 module, we embed our own. + +# +# distutils/version.py +# +# Implements multiple version numbering conventions for the +# Python Module Distribution Utilities. +# +# $Id$ +# + +"""Provides classes to represent module version numbers (one class for +each style of version numbering). There are currently two such classes +implemented: StrictVersion and LooseVersion. + +Every version number class implements the following interface: + * the 'parse' method takes a string and parses it to some internal + representation; if the string is an invalid version number, + 'parse' raises a ValueError exception + * the class constructor takes an optional string argument which, + if supplied, is passed to 'parse' + * __str__ reconstructs the string that was passed to 'parse' (or + an equivalent string -- ie. one that will generate an equivalent + version number instance) + * __repr__ generates Python code to recreate the version number instance + * _cmp compares the current instance with either another instance + of the same class or a string (which will be parsed to an instance + of the same class, thus must follow the same rules) +""" + +import re + + +class Version: + """Abstract base class for version numbering classes. Just provides + constructor (__init__) and reproducer (__repr__), because those + seem to be the same for all version numbering classes; and route + rich comparisons to _cmp. + """ + + def __init__ (self, vstring=None): + if vstring: + self.parse(vstring) + + def __repr__ (self): + return "%s ('%s')" % (self.__class__.__name__, str(self)) + + def __eq__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return c + return c == 0 + + def __lt__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return c + return c < 0 + + def __le__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return c + return c <= 0 + + def __gt__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return c + return c > 0 + + def __ge__(self, other): + c = self._cmp(other) + if c is NotImplemented: + return c + return c >= 0 + + +# Interface for version-number classes -- must be implemented +# by the following classes (the concrete ones -- Version should +# be treated as an abstract class). +# __init__ (string) - create and take same action as 'parse' +# (string parameter is optional) +# parse (string) - convert a string representation to whatever +# internal representation is appropriate for +# this style of version numbering +# __str__ (self) - convert back to a string; should be very similar +# (if not identical to) the string supplied to parse +# __repr__ (self) - generate Python code to recreate +# the instance +# _cmp (self, other) - compare two version numbers ('other' may +# be an unparsed version string, or another +# instance of your version class) + + +class StrictVersion (Version): + + """Version numbering for anal retentives and software idealists. + Implements the standard interface for version number classes as + described above. A version number consists of two or three + dot-separated numeric components, with an optional "pre-release" tag + on the end. The pre-release tag consists of the letter 'a' or 'b' + followed by a number. If the numeric components of two version + numbers are equal, then one with a pre-release tag will always + be deemed earlier (lesser) than one without. + + The following are valid version numbers (shown in the order that + would be obtained by sorting according to the supplied cmp function): + + 0.4 0.4.0 (these two are equivalent) + 0.4.1 + 0.5a1 + 0.5b3 + 0.5 + 0.9.6 + 1.0 + 1.0.4a3 + 1.0.4b1 + 1.0.4 + + The following are examples of invalid version numbers: + + 1 + 2.7.2.2 + 1.3.a4 + 1.3pl1 + 1.3c4 + + The rationale for this version numbering system will be explained + in the distutils documentation. + """ + + version_re = re.compile(r'^(\d+) \. (\d+) (\. (\d+))? ([ab](\d+))?$', + re.VERBOSE | re.ASCII) + + + def parse (self, vstring): + match = self.version_re.match(vstring) + if not match: + raise ValueError("invalid version number '%s'" % vstring) + + (major, minor, patch, prerelease, prerelease_num) = \ + match.group(1, 2, 4, 5, 6) + + if patch: + self.version = tuple(map(int, [major, minor, patch])) + else: + self.version = tuple(map(int, [major, minor])) + (0,) + + if prerelease: + self.prerelease = (prerelease[0], int(prerelease_num)) + else: + self.prerelease = None + + + def __str__ (self): + + if self.version[2] == 0: + vstring = '.'.join(map(str, self.version[0:2])) + else: + vstring = '.'.join(map(str, self.version)) + + if self.prerelease: + vstring = vstring + self.prerelease[0] + str(self.prerelease[1]) + + return vstring + + + def _cmp (self, other): + if isinstance(other, str): + other = StrictVersion(other) + + if self.version != other.version: + # numeric versions don't match + # prerelease stuff doesn't matter + if self.version < other.version: + return -1 + else: + return 1 + + # have to compare prerelease + # case 1: neither has prerelease; they're equal + # case 2: self has prerelease, other doesn't; other is greater + # case 3: self doesn't have prerelease, other does: self is greater + # case 4: both have prerelease: must compare them! + + if (not self.prerelease and not other.prerelease): + return 0 + elif (self.prerelease and not other.prerelease): + return -1 + elif (not self.prerelease and other.prerelease): + return 1 + elif (self.prerelease and other.prerelease): + if self.prerelease == other.prerelease: + return 0 + elif self.prerelease < other.prerelease: + return -1 + else: + return 1 + else: + assert False, "never get here" + +# end class StrictVersion + + +# The rules according to Greg Stein: +# 1) a version number has 1 or more numbers separated by a period or by +# sequences of letters. If only periods, then these are compared +# left-to-right to determine an ordering. +# 2) sequences of letters are part of the tuple for comparison and are +# compared lexicographically +# 3) recognize the numeric components may have leading zeroes +# +# The LooseVersion class below implements these rules: a version number +# string is split up into a tuple of integer and string components, and +# comparison is a simple tuple comparison. This means that version +# numbers behave in a predictable and obvious way, but a way that might +# not necessarily be how people *want* version numbers to behave. There +# wouldn't be a problem if people could stick to purely numeric version +# numbers: just split on period and compare the numbers as tuples. +# However, people insist on putting letters into their version numbers; +# the most common purpose seems to be: +# - indicating a "pre-release" version +# ('alpha', 'beta', 'a', 'b', 'pre', 'p') +# - indicating a post-release patch ('p', 'pl', 'patch') +# but of course this can't cover all version number schemes, and there's +# no way to know what a programmer means without asking him. +# +# The problem is what to do with letters (and other non-numeric +# characters) in a version number. The current implementation does the +# obvious and predictable thing: keep them as strings and compare +# lexically within a tuple comparison. This has the desired effect if +# an appended letter sequence implies something "post-release": +# eg. "0.99" < "0.99pl14" < "1.0", and "5.001" < "5.001m" < "5.002". +# +# However, if letters in a version number imply a pre-release version, +# the "obvious" thing isn't correct. Eg. you would expect that +# "1.5.1" < "1.5.2a2" < "1.5.2", but under the tuple/lexical comparison +# implemented here, this just isn't so. +# +# Two possible solutions come to mind. The first is to tie the +# comparison algorithm to a particular set of semantic rules, as has +# been done in the StrictVersion class above. This works great as long +# as everyone can go along with bondage and discipline. Hopefully a +# (large) subset of Python module programmers will agree that the +# particular flavour of bondage and discipline provided by StrictVersion +# provides enough benefit to be worth using, and will submit their +# version numbering scheme to its domination. The free-thinking +# anarchists in the lot will never give in, though, and something needs +# to be done to accommodate them. +# +# Perhaps a "moderately strict" version class could be implemented that +# lets almost anything slide (syntactically), and makes some heuristic +# assumptions about non-digits in version number strings. This could +# sink into special-case-hell, though; if I was as talented and +# idiosyncratic as Larry Wall, I'd go ahead and implement a class that +# somehow knows that "1.2.1" < "1.2.2a2" < "1.2.2" < "1.2.2pl3", and is +# just as happy dealing with things like "2g6" and "1.13++". I don't +# think I'm smart enough to do it right though. +# +# In any case, I've coded the test suite for this module (see +# ../test/test_version.py) specifically to fail on things like comparing +# "1.2a2" and "1.2". That's not because the *code* is doing anything +# wrong, it's because the simple, obvious design doesn't match my +# complicated, hairy expectations for real-world version numbers. It +# would be a snap to fix the test suite to say, "Yep, LooseVersion does +# the Right Thing" (ie. the code matches the conception). But I'd rather +# have a conception that matches common notions about version numbers. + +class LooseVersion (Version): + + """Version numbering for anarchists and software realists. + Implements the standard interface for version number classes as + described above. A version number consists of a series of numbers, + separated by either periods or strings of letters. When comparing + version numbers, the numeric components will be compared + numerically, and the alphabetic components lexically. The following + are all valid version numbers, in no particular order: + + 1.5.1 + 1.5.2b2 + 161 + 3.10a + 8.02 + 3.4j + 1996.07.12 + 3.2.pl0 + 3.1.1.6 + 2g6 + 11g + 0.960923 + 2.2beta29 + 1.13++ + 5.5.kw + 2.0b1pl0 + + In fact, there is no such thing as an invalid version number under + this scheme; the rules for comparison are simple and predictable, + but may not always give the results you want (for some definition + of "want"). + """ + + component_re = re.compile(r'(\d+ | [a-z]+ | \.)', re.VERBOSE) + + def __init__ (self, vstring=None): + if vstring: + self.parse(vstring) + + + def parse (self, vstring): + # I've given up on thinking I can reconstruct the version string + # from the parsed tuple -- so I just store the string here for + # use by __str__ + self.vstring = vstring + components = [x for x in self.component_re.split(vstring) + if x and x != '.'] + for i, obj in enumerate(components): + try: + components[i] = int(obj) + except ValueError: + pass + + self.version = components + + + def __str__ (self): + return self.vstring + + + def __repr__ (self): + return "LooseVersion ('%s')" % str(self) + + + def _cmp (self, other): + if isinstance(other, str): + other = LooseVersion(other) + + if self.version == other.version: + return 0 + if self.version < other.version: + return -1 + if self.version > other.version: + return 1 + + +# end class LooseVersion diff --git a/src/ethereum_utils.py b/src/ethereum_utils.py index 9ea515a..f0be32a 100644 --- a/src/ethereum_utils.py +++ b/src/ethereum_utils.py @@ -1,55 +1,47 @@ import os +from pyethapp_accounts import Account -class AccountUtils(): + +class AccountUtils: def __init__(self, keystore_dir): - # must be imported after `patch_find_library_android()` - from devp2p.app import BaseApp - from pyethapp.accounts import AccountsService self.keystore_dir = keystore_dir - self.app = BaseApp( - config=dict(accounts=dict(keystore_dir=self.keystore_dir))) - AccountsService.register_with_app(self.app) - self.patch_ethereum_tools_keys() + self._accounts = None def get_account_list(self): """ Returns the Account list. """ - accounts_service = self.app.services.accounts - return accounts_service.accounts + if self._accounts is not None: + return self._accounts + 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) + for keyfile in keyfiles: + account = Account.load(path=keyfile) + self._accounts.append(account) + return self._accounts - def new_account(self, password): + def new_account(self, password, iterations=None): """ Creates an account on the disk and returns it. """ - # lazy loading - from pyethapp.accounts import Account - account = Account.new(password, uuid=None) - account.path = os.path.join( - self.app.services.accounts.keystore_dir, - account.address.hex()) - self.app.services.accounts.add_account(account) + account = Account.new(password, uuid=None, iterations=iterations) + account.path = os.path.join(self.keystore_dir, account.address.hex()) + self.add_account(account) return account - @staticmethod - def patch_ethereum_tools_keys(): - """ - Patches `make_keystore_json()` to use `create_keyfile_json()`, see: - https://github.com/ethereum/pyethapp/issues/292 - """ - # lazy loading - import eth_keyfile - from ethereum.tools import keys - from ethereum.utils import is_string, to_string - keys.make_keystore_json = eth_keyfile.create_keyfile_json - - def decode_keyfile_json(raw_keyfile_json, password): - if not is_string(password): - password = to_string(password) - return eth_keyfile.decode_keyfile_json(raw_keyfile_json, password) - keys.decode_keystore_json = decode_keyfile_json + def add_account(self, account): + with open(account.path, 'w') as f: + f.write(account.dump()) + if self._accounts is None: + self._accounts = [] + self._accounts.append(account) + return account @staticmethod def deleted_account_dir(keystore_dir): @@ -80,7 +72,7 @@ def delete_account(self, account): """ # lazy loading import shutil - keystore_dir = self.app.services.accounts.keystore_dir + 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): @@ -90,6 +82,4 @@ def delete_account(self, account): 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 - accounts_service = self.app.services.accounts - accounts_service.accounts.remove(account) + self._accounts.remove(account) diff --git a/src/etheroll/constants.py b/src/etheroll/constants.py index b777bb5..b09711f 100644 --- a/src/etheroll/constants.py +++ b/src/etheroll/constants.py @@ -1,2 +1,8 @@ +import os + +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) + # default pyethapp keystore path KEYSTORE_DIR_SUFFIX = ".config/pyethapp/keystore/" + +API_KEY_PATH = os.path.join(BASE_DIR, 'api_key.json') diff --git a/src/etheroll/controller.py b/src/etheroll/controller.py index 0597c86..365fcb7 100755 --- a/src/etheroll/controller.py +++ b/src/etheroll/controller.py @@ -10,6 +10,7 @@ from raven import Client from requests.exceptions import ConnectionError +from etheroll.constants import API_KEY_PATH from etheroll.patches import patch_find_library_android from etheroll.settings import SettingsScreen from etheroll.switchaccount import SwitchAccountScreen @@ -78,7 +79,7 @@ def pyetheroll(self): from pyetheroll.etheroll import Etheroll chain_id = SettingsScreen.get_stored_network() if self._pyetheroll is None or self._pyetheroll.chain_id != chain_id: - self._pyetheroll = Etheroll(chain_id) + self._pyetheroll = Etheroll(API_KEY_PATH, chain_id) return self._pyetheroll @property diff --git a/src/etheroll/utils.py b/src/etheroll/utils.py index 761b62d..cae8e8a 100644 --- a/src/etheroll/utils.py +++ b/src/etheroll/utils.py @@ -1,14 +1,6 @@ import threading from io import StringIO -from kivy.logger import LOG_LEVELS -from kivy.utils import platform -from raven import Client -from raven.conf import setup_logging -from raven.handlers.logging import SentryHandler - -from version import __version__ - def run_in_thread(fn): """ @@ -35,53 +27,6 @@ def run(*k, **kw): return run -def configure_sentry(in_debug=False): - """ - Configure the Raven client, or create a dummy one if `in_debug` is `True`. - """ - key = 'b290ecc8934f4cb599e6fa6af6cc5cc2' - # the public DSN URL is not available on the Python client - # so we're exposing the secret and will be revoking it on abuse - # https://github.com/getsentry/raven-python/issues/569 - secret = '0ae02bcb5a75467d9b4431042bb98cb9' - project_id = '1111738' - dsn = 'https://{key}:{secret}@sentry.io/{project_id}'.format( - key=key, secret=secret, project_id=project_id) - if in_debug: - client = DebugRavenClient() - else: - client = Client(dsn=dsn, release=__version__) - # adds context for Android devices - if platform == 'android': - from jnius import autoclass - Build = autoclass("android.os.Build") - VERSION = autoclass('android.os.Build$VERSION') - android_os_build = { - 'model': Build.MODEL, - 'brand': Build.BRAND, - 'device': Build.DEVICE, - 'manufacturer': Build.MANUFACTURER, - 'version_release': VERSION.RELEASE, - } - client.user_context({'android_os_build': android_os_build}) - # Logger.error() to Sentry - # https://docs.sentry.io/clients/python/integrations/logging/ - handler = SentryHandler(client) - handler.setLevel(LOG_LEVELS.get('error')) - setup_logging(handler) - return client - - -class DebugRavenClient(object): - """ - The DebugRavenClient should be used in debug mode, it just raises - the exception rather than capturing it. - """ - - def captureException(self): - raise - - class StringIOCBWrite(StringIO): """ Inherits StringIO, provides callback on write. diff --git a/src/pyethapp_accounts.py b/src/pyethapp_accounts.py new file mode 100644 index 0000000..5cb99a5 --- /dev/null +++ b/src/pyethapp_accounts.py @@ -0,0 +1,207 @@ +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 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/eth-hash/__init__.py b/src/python-for-android/recipes/eth-hash/__init__.py new file mode 100644 index 0000000..86aaa82 --- /dev/null +++ b/src/python-for-android/recipes/eth-hash/__init__.py @@ -0,0 +1,11 @@ +from pythonforandroid.recipe import PythonRecipe + + +class EthHashRecipe(PythonRecipe): + version = '0.2.0' + url = 'https://github.com/ethereum/eth-hash/archive/v{version}.tar.gz' + depends = [('python2', 'python3crystax'), 'setuptools'] + patches = ['disable-setuptools-markdown.patch'] + + +recipe = EthHashRecipe() diff --git a/src/python-for-android/recipes/eth-hash/disable-setuptools-markdown.patch b/src/python-for-android/recipes/eth-hash/disable-setuptools-markdown.patch new file mode 100644 index 0000000..e9ea788 --- /dev/null +++ b/src/python-for-android/recipes/eth-hash/disable-setuptools-markdown.patch @@ -0,0 +1,19 @@ +diff --git a/setup.py b/setup.py +index 8680508..8e9fd09 100644 +--- a/setup.py ++++ b/setup.py +@@ -46,14 +46,12 @@ setup( + # *IMPORTANT*: Don't manually change the version here. Use `make bump`, as described in readme + version='0.2.0', + description="""eth-hash: The Ethereum hashing function, keccak256, sometimes (erroneously) called sha3""", +- long_description_markdown_filename='README.md', + author='Jason Carver', + author_email='ethcalibur+pip@gmail.com', + url='https://github.com/ethereum/eth-hash', + include_package_data=True, + install_requires=[ + ], +- setup_requires=['setuptools-markdown'], + python_requires='>=3.5, <4', + extras_require=extras_require, + py_modules=['eth_hash'], diff --git a/src/python-for-android/recipes/eth-typing/__init__.py b/src/python-for-android/recipes/eth-typing/__init__.py new file mode 100644 index 0000000..84d98d0 --- /dev/null +++ b/src/python-for-android/recipes/eth-typing/__init__.py @@ -0,0 +1,11 @@ +from pythonforandroid.recipe import PythonRecipe + + +class EthTypingRecipe(PythonRecipe): + version = '2.0.0' + url = 'https://github.com/ethereum/eth-typing/archive/v{version}.tar.gz' + depends = [('python2', 'python3crystax'), 'setuptools'] + patches = ['setup.patch'] + + +recipe = EthTypingRecipe() diff --git a/src/python-for-android/recipes/eth-typing/setup.patch b/src/python-for-android/recipes/eth-typing/setup.patch new file mode 100644 index 0000000..0e09754 --- /dev/null +++ b/src/python-for-android/recipes/eth-typing/setup.patch @@ -0,0 +1,18 @@ +diff --git a/setup.py b/setup.py +index 52bf1c1..02be4d0 100644 +--- a/setup.py ++++ b/setup.py +@@ -40,13 +40,10 @@ setup( + # *IMPORTANT*: Don't manually change the version here. Use `make bump`, as described in readme + version='2.0.0', + description="""eth-typing: Common type annotations for ethereum python packages""", +- long_description_markdown_filename='README.md', + author='The eth-typing contributors', + author_email='eth-typing@ethereum.org', + url='https://github.com/ethereum/eth-typing', + include_package_data=True, +- setup_requires=['setuptools-markdown'], +- python_requires='>=3.5, <4', + extras_require=extras_require, + py_modules=['eth_typing'], + license="MIT", diff --git a/src/python-for-android/recipes/web3/__init__.py b/src/python-for-android/recipes/web3/__init__.py new file mode 100644 index 0000000..bb1bc39 --- /dev/null +++ b/src/python-for-android/recipes/web3/__init__.py @@ -0,0 +1,11 @@ +from pythonforandroid.recipe import PythonRecipe + + +class Web3Recipe(PythonRecipe): + version = '4.8.1' + url = 'https://github.com/ethereum/web3.py/archive/v{version}.tar.gz' + depends = [('python2', 'python3crystax'), 'setuptools'] + patches = ['setup.patch'] + + +recipe = Web3Recipe() diff --git a/src/python-for-android/recipes/web3/setup.patch b/src/python-for-android/recipes/web3/setup.patch new file mode 100644 index 0000000..a9c0130 --- /dev/null +++ b/src/python-for-android/recipes/web3/setup.patch @@ -0,0 +1,21 @@ +diff --git a/setup.py b/setup.py +index fc78289..16e422b 100644 +--- a/setup.py ++++ b/setup.py +@@ -62,7 +62,6 @@ setup( + # *IMPORTANT*: Don't manually change the version here. Use the 'bumpversion' utility. + version='4.7.1', + description="""Web3.py""", +- long_description_markdown_filename='README.md', + author='Piper Merriam', + author_email='pipermerriam@gmail.com', + url='https://github.com/ethereum/web3.py', +@@ -80,8 +79,6 @@ setup( + "websockets>=6.0.0,<7.0.0", + "pypiwin32>=223;platform_system=='Windows'", + ], +- setup_requires=['setuptools-markdown'], +- python_requires='>=3.5.3,<4', + extras_require=extras_require, + py_modules=['web3', 'ens'], + license="MIT", diff --git a/src/service/main.py b/src/service/main.py index 0373735..0de9b59 100755 --- a/src/service/main.py +++ b/src/service/main.py @@ -25,7 +25,7 @@ from raven import Client from ethereum_utils import AccountUtils -from etheroll.constants import KEYSTORE_DIR_SUFFIX +from etheroll.constants import API_KEY_PATH, KEYSTORE_DIR_SUFFIX from etheroll.patches import patch_find_library_android from etheroll.store import Store from osc.osc_app_client import OscAppClient @@ -86,7 +86,7 @@ def pyetheroll(self): """ chain_id = self.get_stored_network() if self._pyetheroll is None or self._pyetheroll.chain_id != chain_id: - self._pyetheroll = Etheroll(chain_id) + self._pyetheroll = Etheroll(API_KEY_PATH, chain_id) return self._pyetheroll @staticmethod diff --git a/src/tests/test_ethereum_utils.py b/src/tests/test_ethereum_utils.py index 78d32eb..8bf39ec 100644 --- a/src/tests/test_ethereum_utils.py +++ b/src/tests/test_ethereum_utils.py @@ -25,13 +25,11 @@ def test_new_account(self): 3) tries to unlock the account """ # 1) verifies the current account list is empty - account_list = self.account_utils.get_account_list() - self.assertEqual(len(account_list), 0) + 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) - account_list = self.account_utils.get_account_list() - self.assertEqual(len(account_list), 1) + 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 @@ -42,6 +40,22 @@ def test_new_account(self): 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_deleted_account_dir(self): """ The deleted_account_dir() helper method should be working @@ -67,7 +81,7 @@ def test_delete_account(self): Then verify we can load the account from the backup/trash location. """ password = PASSWORD - account = self.account_utils.new_account(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 @@ -93,7 +107,7 @@ def test_delete_account_already_exists(self): https://github.com/AndreMiras/PyWallet/issues/88 """ password = PASSWORD - account = self.account_utils.new_account(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( diff --git a/src/tests/test_import.py b/src/tests/test_import.py index cf1f560..23ac22c 100644 --- a/src/tests/test_import.py +++ b/src/tests/test_import.py @@ -6,41 +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_utils import AccountUtils - AccountUtils.patch_ethereum_tools_keys() - password = "foobar" - uuid = None - account = Account.new(password, uuid=uuid) - # restore iterations - address = account.address.hex() - self.assertIsNotNone(account) - self.assertIsNotNone(address) def test_pyetheroll(self): from pyetheroll.etheroll import Etheroll diff --git a/src/tests/test_pyethapp_accounts.py b/src/tests/test_pyethapp_accounts.py new file mode 100644 index 0000000..d1f409c --- /dev/null +++ b/src/tests/test_pyethapp_accounts.py @@ -0,0 +1,178 @@ +""" +Adapted version of: +https://github.com/ethereum/pyethapp/blob/7fdec62/ +pyethapp/tests/test_accounts.py +""" +import json +import unittest +from builtins import str +from uuid import uuid4 + +from eth_keys import keys +from eth_utils import remove_0x_prefix +from past.utils import old_div + +from pyethapp_accounts import Account + + +class TestAccountUtils(unittest.TestCase): + + privkey = None + password = None + uuid = None + account = None + keystore = None + + @classmethod + def setUpClass(cls): + cls.privkey = bytes.fromhex( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') + cls.password = 'secret' + cls.uuid = str(uuid4()) + # keystore generation takes a while, so make this module scoped + cls.account = Account.new( + cls.password, cls.privkey, cls.uuid, iterations=1) + # `account.keystore` might not contain address and id + cls.keystore = json.loads(cls.account.dump()) + + def test_account_creation(self): + account = self.account + privkey = self.privkey + uuid = self.uuid + assert not account.locked + assert account.privkey == privkey + pk = keys.PrivateKey(privkey) + assert account.address.hex() == remove_0x_prefix( + pk.public_key.to_address()) + assert account.uuid == uuid + + def test_locked(self): + keystore = self.keystore + uuid = self.uuid + account = Account(keystore) + assert account.locked + assert account.address.hex() == remove_0x_prefix(keystore['address']) + assert account.privkey is None + assert account.pubkey is None + assert account.uuid == uuid + keystore2 = keystore.copy() + keystore2.pop('address') + account = Account(keystore2) + assert account.locked + assert account.address is None + assert account.privkey is None + assert account.pubkey is None + assert account.uuid == uuid + + def test_unlock(self): + keystore = self.keystore + password = self.password + privkey = self.privkey + account = Account(keystore) + assert account.locked + account.unlock(password) + assert not account.locked + assert account.privkey == privkey + pk = keys.PrivateKey(privkey) + assert account.address.hex() == remove_0x_prefix( + pk.public_key.to_address()) + + def test_unlock_wrong(self): + keystore = self.keystore + password = self.password + account = Account(keystore) + assert account.locked + with self.assertRaises(ValueError): + account.unlock(password + '1234') + assert account.locked + with self.assertRaises(ValueError): + account.unlock('4321' + password) + assert account.locked + with self.assertRaises(ValueError): + account.unlock(password[:old_div(len(password), 2)]) + assert account.locked + account.unlock(password) + assert not account.locked + account.unlock(password + 'asdf') + assert not account.locked + account.unlock(password + '1234') + assert not account.locked + + def test_lock(self): + account = self.account + password = self.password + privkey = self.privkey + assert not account.locked + pk = keys.PrivateKey(privkey) + assert account.address.hex() == remove_0x_prefix( + pk.public_key.to_address()) + assert account.privkey == privkey + assert account.pubkey is not None + account.unlock(password + 'fdsa') + account.lock() + assert account.locked + pk = keys.PrivateKey(privkey) + assert account.address.hex() == remove_0x_prefix( + pk.public_key.to_address()) + assert account.privkey is None + assert account.pubkey is None + with self.assertRaises(ValueError): + account.unlock(password + 'fdsa') + account.unlock(password) + + def test_address(self): + keystore = self.keystore + password = self.password + privkey = self.privkey + keystore_wo_address = keystore.copy() + keystore_wo_address.pop('address') + account = Account(keystore_wo_address) + assert account.address is None + account.unlock(password) + account.lock() + pk = keys.PrivateKey(privkey) + assert account.address.hex() == remove_0x_prefix( + pk.public_key.to_address()) + + def test_dump(self): + account = self.account + keystore = json.loads( + account.dump(include_address=True, include_id=True)) + required_keys = set(['crypto', 'version']) + assert set(keystore.keys()) == required_keys | set(['address', 'id']) + assert remove_0x_prefix(keystore['address']) == account.address.hex() + assert keystore['id'] == account.uuid + keystore = json.loads( + account.dump(include_address=False, include_id=True)) + assert set(keystore.keys()) == required_keys | set(['id']) + assert keystore['id'] == account.uuid + keystore = json.loads( + account.dump(include_address=True, include_id=False)) + assert set(keystore.keys()) == required_keys | set(['address']) + assert remove_0x_prefix(keystore['address']) == account.address.hex() + keystore = json.loads( + account.dump(include_address=False, include_id=False)) + assert set(keystore.keys()) == required_keys + + def test_uuid_setting(self): + account = self.account + uuid = account.uuid + account.uuid = 'asdf' + assert account.uuid == 'asdf' + account.uuid = None + assert account.uuid is None + assert 'id' not in account.keystore + account.uuid = uuid + assert account.uuid == uuid + assert account.keystore['id'] == uuid + + # TODO: not yet migrated + # def test_sign(account, password): + # from ethereum.transactions import Transaction + # tx = Transaction(1, 0, 10**6, account.address, 0, '') + # account.sign_tx(tx) + # assert tx.sender == account.address + # account.lock() + # with pytest.raises(ValueError): + # account.sign_tx(tx) + # account.unlock(password) diff --git a/src/testsuite.py b/src/testsuite.py index 5d763dd..ca753b2 100644 --- a/src/testsuite.py +++ b/src/testsuite.py @@ -1,14 +1,11 @@ import unittest from tests import test_ethereum_utils, test_import -from tests.pyetheroll import (test_etheroll, test_etherscan_utils, - test_transaction_debugger, test_utils) def suite(): modules = [ - test_ethereum_utils, test_import, test_etheroll, test_etherscan_utils, - test_transaction_debugger, test_utils + test_ethereum_utils, test_import, ] test_suite = unittest.TestSuite() for module in modules: diff --git a/src/websockets.py b/src/websockets.py new file mode 100644 index 0000000..de97bf0 --- /dev/null +++ b/src/websockets.py @@ -0,0 +1,4 @@ +""" +Dummy module so we don't need to install websockets package. +This is being used by the web3 module. +"""