diff options
Diffstat (limited to 'python-packages/order_utils')
36 files changed, 1131 insertions, 0 deletions
diff --git a/python-packages/order_utils/LICENSE b/python-packages/order_utils/LICENSE new file mode 100644 index 000000000..9096fefaa --- /dev/null +++ b/python-packages/order_utils/LICENSE @@ -0,0 +1,13 @@ +Copyright 2017 ZeroEx Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License.
\ No newline at end of file diff --git a/python-packages/order_utils/README.md b/python-packages/order_utils/README.md new file mode 100644 index 000000000..4c5f7627c --- /dev/null +++ b/python-packages/order_utils/README.md @@ -0,0 +1,45 @@ +## 0x-order-utils + +0x order-related utilities for those developing on top of 0x protocol. + +Read the [documentation](https://0x.readthedocs.io/projects/order-utils/en/latest/) + +## Installing + +```bash +pip install 0x-order-utils +``` + +## Contributing + +We welcome improvements and fixes from the wider community! To report bugs within this package, please create an issue in this repository. + +Please read our [contribution guidelines](../../CONTRIBUTING.md) before getting started. + +### Install Code and Dependencies + +Ensure that you have installed Python >=3.6 and Docker. Then: + +```bash +pip install -e .[dev] +``` + +### Test + +Tests depend on a running ganache instance with the 0x contracts deployed in it. For convenience, a docker container is provided that has ganache-cli and a snapshot containing the necessary contracts. A shortcut is provided to run that docker container: `./setup.py ganache`. With that running, the tests can be run with `./setup.py test`. + +### Clean + +`./setup.py clean --all` + +### Lint + +`./setup.py lint` + +### Build Documentation + +`./setup.py build_sphinx` + +### More + +See `./setup.py --help-commands` for more info. diff --git a/python-packages/order_utils/package.json b/python-packages/order_utils/package.json new file mode 100644 index 000000000..125ca71e0 --- /dev/null +++ b/python-packages/order_utils/package.json @@ -0,0 +1,17 @@ +{ + "comment": "this file exists as an entry point to building this project, specifically for humans that are familiar with yarn and already have it installed. this file is not used in any automation or CI.", + "scripts": { + "install": "pip install -e .[dev]", + "build": "python setup.py build && yarn build:docs", + "build:docs": "python setup.py build_sphinx && sphinx-apidoc -o build/docs/api src", + "test:comment": "test in local environment. to test in all environments, use test:all", + "test": "python setup.py test", + "test:all": "tox", + "test:coverage": "coverage run setup.py test && coveralls", + "lint": "python setup.py lint", + "clean": "python setup.py clean" + }, + "dependencies:comment": "managed in setup.py", + "devDependencies:comment": "managed in setup.py", + "license": "Apache-2.0" +} diff --git a/python-packages/order_utils/setup.py b/python-packages/order_utils/setup.py new file mode 100755 index 000000000..1b07b612c --- /dev/null +++ b/python-packages/order_utils/setup.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python + +"""setuptools module for order_utils package.""" + +import subprocess # nosec +from shutil import rmtree +from os import environ, path +from pathlib import Path +from sys import argv + +from distutils.command.clean import clean +import distutils.command.build_py +from setuptools import find_packages, setup +from setuptools.command.test import test as TestCommand + + +class TestCommandExtension(TestCommand): + """Run pytest tests.""" + + def run_tests(self): + """Invoke pytest.""" + import pytest + + exit(pytest.main()) + + +# pylint: disable=too-many-ancestors +class LintCommand(distutils.command.build_py.build_py): + """Custom setuptools command class for running linters.""" + + description = "Run linters" + + def run(self): + """Run linter shell commands.""" + lint_commands = [ + # formatter: + "black --line-length 79 --check --diff src test setup.py".split(), + # style guide checker (formerly pep8): + "pycodestyle src test setup.py".split(), + # docstring style checker: + "pydocstyle src test setup.py".split(), + # static type checker: + "mypy src test setup.py".split(), + # security issue checker: + "bandit -r src ./setup.py".split(), + # general linter: + "pylint src test setup.py".split(), + # pylint takes relatively long to run, so it runs last, to enable + # fast failures. + ] + + # tell mypy where to find interface stubs for 3rd party libs + environ["MYPYPATH"] = path.join( + path.dirname(path.realpath(argv[0])), "stubs" + ) + + # HACK(gene): until eth_abi releases + # https://github.com/ethereum/eth-abi/pull/107 , we need to simply + # create an empty file `py.typed` in the eth_abi package directory. + import eth_abi + + eth_abi_dir = path.dirname(path.realpath(eth_abi.__file__)) + Path(path.join(eth_abi_dir, "py.typed")).touch() + + # HACK(gene): until eth_utils fixes + # https://github.com/ethereum/eth-utils/issues/140 , we need to simply + # create an empty file `py.typed` in the eth_abi package directory. + import eth_utils + + eth_utils_dir = path.dirname(path.realpath(eth_utils.__file__)) + Path(path.join(eth_utils_dir, "py.typed")).touch() + + for lint_command in lint_commands: + print( + "Running lint command `", " ".join(lint_command).strip(), "`" + ) + subprocess.check_call(lint_command) # nosec + + +class CleanCommandExtension(clean): + """Custom command to do custom cleanup.""" + + def run(self): + """Run the regular clean, followed by our custom commands.""" + super().run() + rmtree("dist", ignore_errors=True) + rmtree(".mypy_cache", ignore_errors=True) + rmtree(".tox", ignore_errors=True) + rmtree(".pytest_cache", ignore_errors=True) + rmtree("src/0x_order_utils.egg-info", ignore_errors=True) + + +# pylint: disable=too-many-ancestors +class TestPublishCommand(distutils.command.build_py.build_py): + """Custom command to publish to test.pypi.org.""" + + description = ( + "Publish dist/* to test.pypi.org. Run sdist & bdist_wheel first." + ) + + def run(self): + """Run twine to upload to test.pypi.org.""" + subprocess.check_call( # nosec + ( + "twine upload --repository-url https://test.pypi.org/legacy/" + + " --verbose dist/*" + ).split() + ) + + +# pylint: disable=too-many-ancestors +class PublishCommand(distutils.command.build_py.build_py): + """Custom command to publish to pypi.org.""" + + description = "Publish dist/* to pypi.org. Run sdist & bdist_wheel first." + + def run(self): + """Run twine to upload to pypi.org.""" + subprocess.check_call("twine upload dist/*".split()) # nosec + + +# pylint: disable=too-many-ancestors +class GanacheCommand(distutils.command.build_py.build_py): + """Custom command to publish to pypi.org.""" + + description = "Run ganache daemon to support tests." + + def run(self): + """Run ganache.""" + cmd_line = ( + "docker run -d -p 8545:8545 0xorg/ganache-cli --gasLimit" + + " 10000000 --db /snapshot --noVMErrorsOnRPCResponse -p 8545" + + " --networkId 50 -m" + ).split() + cmd_line.append( + "concert load couple harbor equip island argue ramp clarify fence" + + " smart topic" + ) + subprocess.call(cmd_line) # nosec + + +with open("README.md", "r") as file_handle: + README_MD = file_handle.read() + + +setup( + name="0x-order-utils", + version="0.1.0", + description="Order utilities for 0x applications", + long_description=README_MD, + long_description_content_type="text/markdown", + url="https://github.com/0xproject/0x-monorepo/python-packages/order_utils", + author="F. Eugene Aumson", + author_email="feuGeneA@users.noreply.github.com", + cmdclass={ + "clean": CleanCommandExtension, + "lint": LintCommand, + "test": TestCommandExtension, + "test_publish": TestPublishCommand, + "publish": PublishCommand, + "ganache": GanacheCommand, + }, + install_requires=["eth-abi", "eth_utils", "mypy_extensions", "web3"], + extras_require={ + "dev": [ + "bandit", + "black", + "coverage", + "coveralls", + "mypy", + "mypy_extensions", + "pycodestyle", + "pydocstyle", + "pylint", + "pytest", + "sphinx", + "tox", + "twine", + ] + }, + python_requires=">=3.6, <4", + package_data={ + "zero_ex.order_utils": ["py.typed"], + "zero_ex.contract_artifacts": ["artifacts/*"], + }, + package_dir={"": "src"}, + license="Apache 2.0", + keywords=( + "ethereum cryptocurrency 0x decentralized blockchain dex exchange" + ), + namespace_packages=["zero_ex"], + packages=find_packages("src"), + classifiers=[ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Financial and Insurance Industry", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Office/Business :: Financial", + "Topic :: Other/Nonlisted Topic", + "Topic :: Security :: Cryptography", + "Topic :: Software Development :: Libraries", + "Topic :: Utilities", + ], + zip_safe=False, # required per mypy + command_options={ + "build_sphinx": { + "source_dir": ("setup.py", "src"), + "build_dir": ("setup.py", "build/docs"), + } + }, +) diff --git a/python-packages/order_utils/src/conf.py b/python-packages/order_utils/src/conf.py new file mode 100644 index 000000000..6b6776d01 --- /dev/null +++ b/python-packages/order_utils/src/conf.py @@ -0,0 +1,54 @@ +"""Configuration file for the Sphinx documentation builder.""" + +# Reference: http://www.sphinx-doc.org/en/master/config + +from typing import List +import pkg_resources + + +# pylint: disable=invalid-name +# because these variables are not named in upper case, as globals should be. + +project = "0x-order-utils" +# pylint: disable=redefined-builtin +copyright = "2018, ZeroEx, Intl." +author = "F. Eugene Aumson" +version = pkg_resources.get_distribution("0x-order-utils").version +release = "" # The full version, including alpha/beta/rc tags + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.doctest", + "sphinx.ext.intersphinx", + "sphinx.ext.coverage", + "sphinx.ext.viewcode", +] + +templates_path = ["doc_templates"] + +source_suffix = ".rst" +# eg: source_suffix = [".rst", ".md"] + +master_doc = "index" # The master toctree document. + +language = None + +exclude_patterns: List[str] = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = None + +html_theme = "alabaster" + +html_static_path = ["doc_static"] +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". + +# Output file base name for HTML help builder. +htmlhelp_basename = "order_utilspydoc" + +# -- Extension configuration: + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {"https://docs.python.org/": None} diff --git a/python-packages/order_utils/src/doc_static/.gitkeep b/python-packages/order_utils/src/doc_static/.gitkeep new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/python-packages/order_utils/src/doc_static/.gitkeep diff --git a/python-packages/order_utils/src/doc_templates/.gitkeep b/python-packages/order_utils/src/doc_templates/.gitkeep new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/python-packages/order_utils/src/doc_templates/.gitkeep diff --git a/python-packages/order_utils/src/index.rst b/python-packages/order_utils/src/index.rst new file mode 100644 index 000000000..b99addabd --- /dev/null +++ b/python-packages/order_utils/src/index.rst @@ -0,0 +1,30 @@ +.. source for the sphinx-generated build/docs/web/index.html + +Python zero_ex.order_utils +========================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +.. automodule:: zero_ex.order_utils + :members: + +.. automodule:: zero_ex.order_utils.asset_data_utils + :members: + +.. autoclass:: zero_ex.order_utils.asset_data_utils.ERC20AssetData + +.. autoclass:: zero_ex.order_utils.asset_data_utils.ERC721AssetData + +See source for class properties. Sphinx does not easily generate class property docs; pull requests welcome. + +.. automodule:: zero_ex.order_utils.signature_utils + :members: + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/python-packages/order_utils/src/zero_ex/__init__.py b/python-packages/order_utils/src/zero_ex/__init__.py new file mode 100644 index 000000000..e90d833db --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/__init__.py @@ -0,0 +1,2 @@ +"""0x Python API.""" +__import__("pkg_resources").declare_namespace(__name__) diff --git a/python-packages/order_utils/src/zero_ex/contract_artifacts/__init__.py b/python-packages/order_utils/src/zero_ex/contract_artifacts/__init__.py new file mode 100644 index 000000000..ed45d2c8e --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/contract_artifacts/__init__.py @@ -0,0 +1 @@ +"""Solc-generated artifacts for 0x smart contracts.""" diff --git a/python-packages/order_utils/src/zero_ex/contract_artifacts/artifacts b/python-packages/order_utils/src/zero_ex/contract_artifacts/artifacts new file mode 120000 index 000000000..82d28ba87 --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/contract_artifacts/artifacts @@ -0,0 +1 @@ +../../../../../packages/contract-artifacts/artifacts
\ No newline at end of file diff --git a/python-packages/order_utils/src/zero_ex/dev_utils/__init__.py b/python-packages/order_utils/src/zero_ex/dev_utils/__init__.py new file mode 100644 index 000000000..b6a224d2c --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/dev_utils/__init__.py @@ -0,0 +1 @@ +"""Dev utils to be shared across 0x projects and packages.""" diff --git a/python-packages/order_utils/src/zero_ex/dev_utils/abi_utils.py b/python-packages/order_utils/src/zero_ex/dev_utils/abi_utils.py new file mode 100644 index 000000000..71b6128ca --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/dev_utils/abi_utils.py @@ -0,0 +1,102 @@ +"""Ethereum ABI utilities. + +Builds on the eth-abi package, adding some convenience methods like those found +in npmjs.com/package/ethereumjs-abi. Ideally, all of this code should be +pushed upstream into eth-abi. +""" + +import re +from typing import Any, List + +from mypy_extensions import TypedDict + +from eth_abi import encode_abi +from web3 import Web3 + +from .type_assertions import assert_is_string, assert_is_list + + +class MethodSignature(TypedDict, total=False): + """Object interface to an ABI method signature.""" + + method: str + args: List[str] + + +def parse_signature(signature: str) -> MethodSignature: + """Parse a method signature into its constituent parts. + + >>> parse_signature("ERC20Token(address)") + {'method': 'ERC20Token', 'args': ['address']} + """ + assert_is_string(signature, "signature") + + matches = re.match(r"^(\w+)\((.+)\)$", signature) + if matches is None: + raise ValueError(f"Invalid method signature {signature}") + return {"method": matches[1], "args": matches[2].split(",")} + + +def elementary_name(name: str) -> str: + """Convert from short to canonical names; barely implemented. + + Modeled after ethereumjs-abi's ABI.elementaryName(), but only implemented + to support our particular use case and a few other simple ones. + + >>> elementary_name("address") + 'address' + >>> elementary_name("uint") + 'uint256' + """ + assert_is_string(name, "name") + + return { + "int": "int256", + "uint": "uint256", + "fixed": "fixed128x128", + "ufixed": "ufixed128x128", + }.get(name, name) + + +def event_id(name: str, types: List[str]) -> str: + """Return the Keccak-256 hash of the given method. + + >>> event_id("ERC20Token", ["address"]) + '0xf47261b06eedbfce68afd46d0f3c27c60b03faad319eaf33103611cf8f6456ad' + """ + assert_is_string(name, "name") + assert_is_list(types, "types") + + signature = f"{name}({','.join(list(map(elementary_name, types)))})" + return Web3.sha3(text=signature).hex() + + +def method_id(name: str, types: List[str]) -> str: + """Return the 4-byte method identifier. + + >>> method_id("ERC20Token", ["address"]) + '0xf47261b0' + """ + assert_is_string(name, "name") + assert_is_list(types, "types") + + return event_id(name, types)[0:10] + + +def simple_encode(method: str, *args: Any) -> bytes: + # docstring considered all one line by pylint: disable=line-too-long + r"""Encode a method ABI. + + >>> simple_encode("ERC20Token(address)", "0x1dc4c1cefef38a777b15aa20260a54e584b16c48") + b'\xf4ra\xb0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1d\xc4\xc1\xce\xfe\xf3\x8aw{\x15\xaa &\nT\xe5\x84\xb1lH' + """ # noqa: E501 (line too long) + assert_is_string(method, "method") + + signature: MethodSignature = parse_signature(method) + + return bytes.fromhex( + ( + method_id(signature["method"], signature["args"]) + + encode_abi(signature["args"], args).hex() + )[2:] + ) diff --git a/python-packages/order_utils/src/zero_ex/dev_utils/type_assertions.py b/python-packages/order_utils/src/zero_ex/dev_utils/type_assertions.py new file mode 100644 index 000000000..08c1b0ea5 --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/dev_utils/type_assertions.py @@ -0,0 +1,58 @@ +"""Assertions for runtime type checking of function arguments.""" + +from typing import Any + + +def assert_is_string(value: Any, name: str) -> None: + """If :param value: isn't of type str, raise a TypeError. + + >>> try: assert_is_string(123, 'var') + ... except TypeError as type_error: print(str(type_error)) + ... + expected variable 'var', with value 123, to have type 'str', not 'int' + """ + if not isinstance(value, str): + raise TypeError( + f"expected variable '{name}', with value {str(value)}, to have" + + f" type 'str', not '{type(value).__name__}'" + ) + + +def assert_is_list(value: Any, name: str) -> None: + """If :param value: isn't of type list, raise a TypeError. + + >>> try: assert_is_list(123, 'var') + ... except TypeError as type_error: print(str(type_error)) + ... + expected variable 'var', with value 123, to have type 'list', not 'int' + """ + if not isinstance(value, list): + raise TypeError( + f"expected variable '{name}', with value {str(value)}, to have" + + f" type 'list', not '{type(value).__name__}'" + ) + + +def assert_is_int(value: Any, name: str) -> None: + """If :param value: isn't of type int, raise a TypeError. + + >>> try: assert_is_int('asdf', 'var') + ... except TypeError as type_error: print(str(type_error)) + ... + expected variable 'var', with value asdf, to have type 'int', not 'str' + """ + if not isinstance(value, int): + raise TypeError( + f"expected variable '{name}', with value {str(value)}, to have" + + f" type 'int', not '{type(value).__name__}'" + ) + + +def assert_is_hex_string(value: Any, name: str) -> None: + """Assert that :param value: is a string of hex chars. + + If :param value: isn't a str, raise a TypeError. If it is a string but + contains non-hex characters ("0x" prefix permitted), raise a ValueError. + """ + assert_is_string(value, name) + int(value, 16) # raises a ValueError if value isn't a base-16 str diff --git a/python-packages/order_utils/src/zero_ex/order_utils/__init__.py b/python-packages/order_utils/src/zero_ex/order_utils/__init__.py new file mode 100644 index 000000000..80445cb6e --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/order_utils/__init__.py @@ -0,0 +1,11 @@ +"""Order utilities for 0x applications. + +Some methods require the caller to pass in a `Web3.HTTPProvider` object. For +local testing one may construct such a provider pointing at an instance of +`ganache-cli <https://www.npmjs.com/package/ganache-cli>`_ which has the 0x +contracts deployed on it. For convenience, a docker container is provided for +just this purpose. To start it: ``docker run -d -p 8545:8545 0xorg/ganache-cli +--gasLimit 10000000 --db /snapshot --noVMErrorsOnRPCResponse -p 8545 +--networkId 50 -m "concert load couple harbor equip island argue ramp clarify +fence smart topic"``. +""" diff --git a/python-packages/order_utils/src/zero_ex/order_utils/asset_data_utils.py b/python-packages/order_utils/src/zero_ex/order_utils/asset_data_utils.py new file mode 100644 index 000000000..e6f9a07c1 --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/order_utils/asset_data_utils.py @@ -0,0 +1,143 @@ +"""Asset data encoding and decoding utilities.""" + +from mypy_extensions import TypedDict + +import eth_abi + +from zero_ex.dev_utils import abi_utils +from zero_ex.dev_utils.type_assertions import assert_is_string, assert_is_int + + +ERC20_ASSET_DATA_BYTE_LENGTH = 36 +ERC721_ASSET_DATA_MINIMUM_BYTE_LENGTH = 53 +SELECTOR_LENGTH = 10 + + +class ERC20AssetData(TypedDict): + """Object interface to ERC20 asset data.""" + + asset_proxy_id: str + token_address: str + + +class ERC721AssetData(TypedDict): + """Object interface to ERC721 asset data.""" + + asset_proxy_id: str + token_address: str + token_id: int + + +def encode_erc20_asset_data(token_address: str) -> str: + """Encode an ERC20 token address into an asset data string. + + :param token_address: the ERC20 token's contract address. + :rtype: hex encoded asset data string, usable in the makerAssetData or + takerAssetData fields in a 0x order. + + >>> encode_erc20_asset_data('0x1dc4c1cefef38a777b15aa20260a54e584b16c48') + '0xf47261b00000000000000000000000001dc4c1cefef38a777b15aa20260a54e584b16c48' + """ + assert_is_string(token_address, "token_address") + + return ( + "0x" + + abi_utils.simple_encode("ERC20Token(address)", token_address).hex() + ) + + +def decode_erc20_asset_data(asset_data: str) -> ERC20AssetData: + # docstring considered all one line by pylint: disable=line-too-long + """Decode an ERC20 asset data hex string. + + :param asset_data: String produced by prior call to encode_erc20_asset_data() + + >>> decode_erc20_asset_data("0xf47261b00000000000000000000000001dc4c1cefef38a777b15aa20260a54e584b16c48") + {'asset_proxy_id': '0xf47261b0', 'token_address': '0x1dc4c1cefef38a777b15aa20260a54e584b16c48'} + """ # noqa: E501 (line too long) + assert_is_string(asset_data, "asset_data") + + if len(asset_data) < ERC20_ASSET_DATA_BYTE_LENGTH: + raise ValueError( + "Could not decode ERC20 Proxy Data. Expected length of encoded" + + f" data to be at least {str(ERC20_ASSET_DATA_BYTE_LENGTH)}." + + f" Got {str(len(asset_data))}." + ) + + asset_proxy_id: str = asset_data[0:SELECTOR_LENGTH] + if asset_proxy_id != abi_utils.method_id("ERC20Token", ["address"]): + raise ValueError( + "Could not decode ERC20 Proxy Data. Expected Asset Proxy Id to be" + + f" ERC20 ({abi_utils.method_id('ERC20Token', ['address'])})" + + f" but got {asset_proxy_id}." + ) + + # workaround for https://github.com/PyCQA/pylint/issues/1498 + # pylint: disable=unsubscriptable-object + token_address = eth_abi.decode_abi( + ["address"], bytes.fromhex(asset_data[SELECTOR_LENGTH:]) + )[0] + + return {"asset_proxy_id": asset_proxy_id, "token_address": token_address} + + +def encode_erc721_asset_data(token_address: str, token_id: int) -> str: + # docstring considered all one line by pylint: disable=line-too-long + """Encode an ERC721 asset data hex string. + + :param token_address: the ERC721 token's contract address. + :param token_id: the identifier of the asset's instance of the token. + :rtype: hex encoded asset data string, usable in the makerAssetData or + takerAssetData fields in a 0x order. + + >>> encode_erc721_asset_data('0x1dc4c1cefef38a777b15aa20260a54e584b16c48', 1) + '0x025717920000000000000000000000001dc4c1cefef38a777b15aa20260a54e584b16c480000000000000000000000000000000000000000000000000000000000000001' + """ # noqa: E501 (line too long) + assert_is_string(token_address, "token_address") + assert_is_int(token_id, "token_id") + + return ( + "0x" + + abi_utils.simple_encode( + "ERC721Token(address,uint256)", token_address, token_id + ).hex() + ) + + +def decode_erc721_asset_data(asset_data: str) -> ERC721AssetData: + # docstring considered all one line by pylint: disable=line-too-long + """Decode an ERC721 asset data hex string. + + >>> decode_erc721_asset_data('0x025717920000000000000000000000001dc4c1cefef38a777b15aa20260a54e584b16c480000000000000000000000000000000000000000000000000000000000000001') + {'asset_proxy_id': '0x02571792', 'token_address': '0x1dc4c1cefef38a777b15aa20260a54e584b16c48', 'token_id': 1} + """ # noqa: E501 (line too long) + assert_is_string(asset_data, "asset_data") + + if len(asset_data) < ERC721_ASSET_DATA_MINIMUM_BYTE_LENGTH: + raise ValueError( + "Could not decode ERC721 Asset Data. Expected length of encoded" + + f"data to be at least {ERC721_ASSET_DATA_MINIMUM_BYTE_LENGTH}. " + + f"Got {len(asset_data)}." + ) + + asset_proxy_id: str = asset_data[0:SELECTOR_LENGTH] + # prefer `black` formatting. pylint: disable=C0330 + if asset_proxy_id != abi_utils.method_id( + "ERC721Token", ["address", "uint256"] + ): + raise ValueError( + "Could not decode ERC721 Asset Data. Expected Asset Proxy Id to be" + + f" ERC721 (" + + f"{abi_utils.method_id('ERC721Token', ['address', 'uint256'])}" + + f"), but got {asset_proxy_id}" + ) + + (token_address, token_id) = eth_abi.decode_abi( + ["address", "uint256"], bytes.fromhex(asset_data[SELECTOR_LENGTH:]) + ) + + return { + "asset_proxy_id": asset_proxy_id, + "token_address": token_address, + "token_id": token_id, + } diff --git a/python-packages/order_utils/src/zero_ex/order_utils/py.typed b/python-packages/order_utils/src/zero_ex/order_utils/py.typed new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/order_utils/py.typed diff --git a/python-packages/order_utils/src/zero_ex/order_utils/signature_utils.py b/python-packages/order_utils/src/zero_ex/order_utils/signature_utils.py new file mode 100644 index 000000000..12525ba88 --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/order_utils/signature_utils.py @@ -0,0 +1,88 @@ +"""Signature utilities.""" + +from typing import Dict, Tuple +import json +from pkg_resources import resource_string + +from eth_utils import is_address, to_checksum_address +from web3 import Web3 +import web3.exceptions +from web3.utils import datatypes + +from zero_ex.dev_utils.type_assertions import assert_is_hex_string + + +# prefer `black` formatting. pylint: disable=C0330 +EXCHANGE_ABI = json.loads( + resource_string("zero_ex.contract_artifacts", "artifacts/Exchange.json") +)["compilerOutput"]["abi"] + +network_to_exchange_addr: Dict[str, str] = { + "1": "0x4f833a24e1f95d70f028921e27040ca56e09ab0b", + "3": "0x4530c0483a1633c7a1c97d2c53721caff2caaaaf", + "42": "0x35dd2932454449b14cee11a94d3674a936d5d7b2", + "50": "0x48bacb9266a570d521063ef5dd96e61686dbe788", +} + + +# prefer `black` formatting. pylint: disable=C0330 +def is_valid_signature( + provider: Web3.HTTPProvider, data: str, signature: str, signer_address: str +) -> Tuple[bool, str]: + # docstring considered all one line by pylint: disable=line-too-long + """Check the validity of the supplied signature. + + Check if the supplied ``signature`` corresponds to signing ``data`` with + the private key corresponding to ``signer_address``. + + :param provider: A Web3 provider able to access the 0x Exchange contract. + :param data: The hex encoded data signed by the supplied signature. + :param signature: The hex encoded signature. + :param signer_address: The hex encoded address that signed the data to + produce the supplied signature. + :rtype: Boolean indicating whether the given signature is valid. + + >>> is_valid_signature( + ... Web3.HTTPProvider("http://127.0.0.1:8545"), + ... '0x6927e990021d23b1eb7b8789f6a6feaf98fe104bb0cf8259421b79f9a34222b0', + ... '0x1B61a3ed31b43c8780e905a260a35faefcc527be7516aa11c0256729b5b351bc3340349190569279751135161d22529dc25add4f6069af05be04cacbda2ace225403', + ... '0x5409ed021d9299bf6814279a6a1411a7e866a631', + ... ) + (True, '') + """ # noqa: E501 (line too long) + # TODO: make this provider check more flexible. pylint: disable=fixme + # https://app.asana.com/0/684263176955174/901300863045491/f + if not isinstance(provider, Web3.HTTPProvider): + raise TypeError("provider is not a Web3.HTTPProvider") + assert_is_hex_string(data, "data") + assert_is_hex_string(signature, "signature") + assert_is_hex_string(signer_address, "signer_address") + if not is_address(signer_address): + raise ValueError("signer_address is not a valid address") + + web3_instance = Web3(provider) + # false positive from pylint: disable=no-member + network_id = web3_instance.net.version + contract_address = network_to_exchange_addr[network_id] + # false positive from pylint: disable=no-member + contract: datatypes.Contract = web3_instance.eth.contract( + address=to_checksum_address(contract_address), abi=EXCHANGE_ABI + ) + try: + return ( + contract.call().isValidSignature( + data, to_checksum_address(signer_address), signature + ), + "", + ) + except web3.exceptions.BadFunctionCallOutput as exception: + known_revert_reasons = [ + "LENGTH_GREATER_THAN_0_REQUIRED", + "SIGNATURE_UNSUPPORTED", + "LENGTH_0_REQUIRED", + "LENGTH_65_REQUIRED", + ] + for known_revert_reason in known_revert_reasons: + if known_revert_reason in str(exception): + return (False, known_revert_reason) + return (False, f"Unknown: {exception}") diff --git a/python-packages/order_utils/stubs/distutils/__init__.pyi b/python-packages/order_utils/stubs/distutils/__init__.pyi new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/python-packages/order_utils/stubs/distutils/__init__.pyi diff --git a/python-packages/order_utils/stubs/distutils/command/__init__.pyi b/python-packages/order_utils/stubs/distutils/command/__init__.pyi new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/python-packages/order_utils/stubs/distutils/command/__init__.pyi diff --git a/python-packages/order_utils/stubs/distutils/command/clean.pyi b/python-packages/order_utils/stubs/distutils/command/clean.pyi new file mode 100644 index 000000000..46a42ddb1 --- /dev/null +++ b/python-packages/order_utils/stubs/distutils/command/clean.pyi @@ -0,0 +1,7 @@ +from distutils.core import Command + +class clean(Command): + def initialize_options(self: clean) -> None: ... + def finalize_options(self: clean) -> None: ... + def run(self: clean) -> None: ... + ... diff --git a/python-packages/order_utils/stubs/pytest/__init__.pyi b/python-packages/order_utils/stubs/pytest/__init__.pyi new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/python-packages/order_utils/stubs/pytest/__init__.pyi diff --git a/python-packages/order_utils/stubs/pytest/raises.pyi b/python-packages/order_utils/stubs/pytest/raises.pyi new file mode 100644 index 000000000..2e3b29f3d --- /dev/null +++ b/python-packages/order_utils/stubs/pytest/raises.pyi @@ -0,0 +1 @@ +def raises(exception: Exception) -> ExceptionInfo: ... diff --git a/python-packages/order_utils/stubs/setuptools/__init__.pyi b/python-packages/order_utils/stubs/setuptools/__init__.pyi new file mode 100644 index 000000000..8ea8d32b7 --- /dev/null +++ b/python-packages/order_utils/stubs/setuptools/__init__.pyi @@ -0,0 +1,8 @@ +from distutils.dist import Distribution +from typing import Any, List + +def setup(**attrs: Any) -> Distribution: ... + +class Command: ... + +def find_packages(where: str) -> List[str]: ... diff --git a/python-packages/order_utils/stubs/setuptools/command/__init__.pyi b/python-packages/order_utils/stubs/setuptools/command/__init__.pyi new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/python-packages/order_utils/stubs/setuptools/command/__init__.pyi diff --git a/python-packages/order_utils/stubs/setuptools/command/test.pyi b/python-packages/order_utils/stubs/setuptools/command/test.pyi new file mode 100644 index 000000000..c5ec770ad --- /dev/null +++ b/python-packages/order_utils/stubs/setuptools/command/test.pyi @@ -0,0 +1,3 @@ +from setuptools import Command + +class test(Command): ... diff --git a/python-packages/order_utils/stubs/web3/__init__.pyi b/python-packages/order_utils/stubs/web3/__init__.pyi new file mode 100644 index 000000000..fcecc7434 --- /dev/null +++ b/python-packages/order_utils/stubs/web3/__init__.pyi @@ -0,0 +1,26 @@ +from typing import Dict, Optional, Union + +from web3.utils import datatypes + + +class Web3: + class HTTPProvider: ... + + def __init__(self, provider: HTTPProvider) -> None: ... + + @staticmethod + def sha3( + primitive: Optional[Union[bytes, int, None]] = None, + text: Optional[str] = None, + hexstr: Optional[str] = None + ) -> bytes: ... + + class net: + version: str + ... + + class eth: + @staticmethod + def contract(address: str, abi: Dict) -> datatypes.Contract: ... + ... + ... diff --git a/python-packages/order_utils/stubs/web3/exceptions.pyi b/python-packages/order_utils/stubs/web3/exceptions.pyi new file mode 100644 index 000000000..83abf973d --- /dev/null +++ b/python-packages/order_utils/stubs/web3/exceptions.pyi @@ -0,0 +1,2 @@ +class BadFunctionCallOutput(Exception): + ... diff --git a/python-packages/order_utils/stubs/web3/utils/__init__.pyi b/python-packages/order_utils/stubs/web3/utils/__init__.pyi new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/python-packages/order_utils/stubs/web3/utils/__init__.pyi diff --git a/python-packages/order_utils/stubs/web3/utils/datatypes.pyi b/python-packages/order_utils/stubs/web3/utils/datatypes.pyi new file mode 100644 index 000000000..70baff372 --- /dev/null +++ b/python-packages/order_utils/stubs/web3/utils/datatypes.pyi @@ -0,0 +1,3 @@ +class Contract: + def call(self): ... + ... diff --git a/python-packages/order_utils/test/__init__.py b/python-packages/order_utils/test/__init__.py new file mode 100644 index 000000000..ec5b114aa --- /dev/null +++ b/python-packages/order_utils/test/__init__.py @@ -0,0 +1 @@ +"""Tests of zero_x.order_utils.""" diff --git a/python-packages/order_utils/test/test_abi_utils.py b/python-packages/order_utils/test/test_abi_utils.py new file mode 100644 index 000000000..49a2a4f20 --- /dev/null +++ b/python-packages/order_utils/test/test_abi_utils.py @@ -0,0 +1,53 @@ +"""Tests of 0x.abi_utils.""" + +import pytest + +from zero_ex.dev_utils.abi_utils import ( + elementary_name, + event_id, + method_id, + parse_signature, + simple_encode, +) + + +def test_parse_signature_type_error(): + """Test that passing in wrong types raises TypeError.""" + with pytest.raises(TypeError): + parse_signature(123) + + +def test_parse_signature_bad_input(): + """Test that passing a non-signature string raises a ValueError.""" + with pytest.raises(ValueError): + parse_signature("a string that's not even close to a signature") + + +def test_elementary_name_type_error(): + """Test that passing in wrong types raises TypeError.""" + with pytest.raises(TypeError): + elementary_name(123) + + +def test_event_id_type_error(): + """Test that passing in wrong types raises TypeError.""" + with pytest.raises(TypeError): + event_id(123, []) + + with pytest.raises(TypeError): + event_id("valid string", 123) + + +def test_method_id_type_error(): + """Test that passing in wrong types raises TypeError.""" + with pytest.raises(TypeError): + method_id(123, []) + + with pytest.raises(TypeError): + method_id("ERC20Token", 123) + + +def test_simple_encode_type_error(): + """Test that passing in wrong types raises TypeError.""" + with pytest.raises(TypeError): + simple_encode(123) diff --git a/python-packages/order_utils/test/test_asset_data_utils.py b/python-packages/order_utils/test/test_asset_data_utils.py new file mode 100644 index 000000000..079368714 --- /dev/null +++ b/python-packages/order_utils/test/test_asset_data_utils.py @@ -0,0 +1,72 @@ +"""Tests of 0x.order_utils.asset_data_utils.""" + +import pytest + +from zero_ex.order_utils.asset_data_utils import ( + decode_erc20_asset_data, + decode_erc721_asset_data, + encode_erc20_asset_data, + encode_erc721_asset_data, + ERC20_ASSET_DATA_BYTE_LENGTH, + ERC721_ASSET_DATA_MINIMUM_BYTE_LENGTH, +) + + +def test_encode_erc20_asset_data_type_error(): + """Test that passing in a non-string raises a TypeError.""" + with pytest.raises(TypeError): + encode_erc20_asset_data(123) + + +def test_decode_erc20_asset_data_type_error(): + """Test that passing in a non-string raises a TypeError.""" + with pytest.raises(TypeError): + decode_erc20_asset_data(123) + + +def test_decode_erc20_asset_data_too_short(): + """Test that passing an insufficiently long string raises a ValueError.""" + with pytest.raises(ValueError): + decode_erc20_asset_data(" " * (ERC20_ASSET_DATA_BYTE_LENGTH - 1)) + + +def test_decode_erc20_asset_data_invalid_proxy_id(): + """Test that passing data with an invalid proxy ID raises a ValueError.""" + with pytest.raises(ValueError): + decode_erc20_asset_data( + "0xffffffff" + (" " * ERC20_ASSET_DATA_BYTE_LENGTH) + ) + + +def test_encode_erc721_asset_data_type_error_on_token_address(): + """Test that passing a non-string for token_address raises a TypeError.""" + with pytest.raises(TypeError): + encode_erc721_asset_data(123, 123) + + +def test_encode_erc721_asset_data_type_error_on_token_id(): + """Test that passing a non-int for token_id raises a TypeError.""" + with pytest.raises(TypeError): + encode_erc721_asset_data("asdf", "asdf") + + +def test_decode_erc721_asset_data_type_error(): + """Test that passing a non-string for asset_data raises a TypeError.""" + with pytest.raises(TypeError): + decode_erc721_asset_data(123) + + +def test_decode_erc721_asset_data_with_asset_data_too_short(): + """Test that passing in too short of a string raises a ValueError.""" + with pytest.raises(ValueError): + decode_erc721_asset_data( + " " * (ERC721_ASSET_DATA_MINIMUM_BYTE_LENGTH - 1) + ) + + +def test_decode_erc721_asset_data_invalid_proxy_id(): + """Test that passing in too short of a string raises a ValueError.""" + with pytest.raises(ValueError): + decode_erc721_asset_data( + "0xffffffff" + " " * (ERC721_ASSET_DATA_MINIMUM_BYTE_LENGTH - 1) + ) diff --git a/python-packages/order_utils/test/test_doctest.py b/python-packages/order_utils/test/test_doctest.py new file mode 100644 index 000000000..2b0350ac0 --- /dev/null +++ b/python-packages/order_utils/test/test_doctest.py @@ -0,0 +1,18 @@ +"""Exercise doctests for all of our modules.""" + +from doctest import testmod +import pkgutil + +import zero_ex + + +def test_all_doctests(): + """Gather zero_ex.* modules and doctest them.""" + # prefer `black` formatting. pylint: disable=bad-continuation + for (importer, modname, _) in pkgutil.walk_packages( + path=zero_ex.__path__, prefix="zero_ex." + ): + module = importer.find_module(modname).load_module(modname) + print(module) + (failure_count, _) = testmod(module) + assert failure_count == 0 diff --git a/python-packages/order_utils/test/test_signature_utils.py b/python-packages/order_utils/test/test_signature_utils.py new file mode 100644 index 000000000..b688e03a1 --- /dev/null +++ b/python-packages/order_utils/test/test_signature_utils.py @@ -0,0 +1,128 @@ +"""Tests of zero_ex.order_utils.signature_utils.""" + +import pytest +from web3 import Web3 + +from zero_ex.order_utils.signature_utils import is_valid_signature + + +def test_is_valid_signature__provider_wrong_type(): + """Test that giving a non-HTTPProvider raises a TypeError.""" + with pytest.raises(TypeError): + is_valid_signature( + 123, + "0x6927e990021d23b1eb7b8789f6a6feaf98fe104bb0cf8259421b79f9a34222b" + + "0", + "0x1B61a3ed31b43c8780e905a260a35faefcc527be7516aa11c0256729b5b351b" + + "c3340349190569279751135161d22529dc25add4f6069af05be04cacbda2ace" + + "225403", + "0x5409ed021d9299bf6814279a6a1411a7e866a631", + ) + + +def test_is_valid_signature__data_not_string(): + """Test that giving non-string `data` raises a TypeError.""" + with pytest.raises(TypeError): + is_valid_signature( + Web3.HTTPProvider("http://127.0.0.1:8545"), + 123, + "0x1B61a3ed31b43c8780e905a260a35faefcc527be7516aa11c0256729b5b351b" + + "c3340349190569279751135161d22529dc25add4f6069af05be04cacbda2ace" + + "225403", + "0x5409ed021d9299bf6814279a6a1411a7e866a631", + ) + + +def test_is_valid_signature__data_not_hex_string(): + """Test that giving non-hex-string `data` raises a ValueError.""" + with pytest.raises(ValueError): + is_valid_signature( + Web3.HTTPProvider("http://127.0.0.1:8545"), + "jjj", + "0x1B61a3ed31b43c8780e905a260a35faefcc527be7516aa11c0256729b5b351b" + + "c3340349190569279751135161d22529dc25add4f6069af05be04cacbda2ace" + + "225403", + "0x5409ed021d9299bf6814279a6a1411a7e866a631", + ) + + +def test_is_valid_signature__signature_not_string(): + """Test that passng a non-string signature raises a TypeError.""" + with pytest.raises(TypeError): + is_valid_signature( + Web3.HTTPProvider("http://127.0.0.1:8545"), + "0x6927e990021d23b1eb7b8789f6a6feaf98fe104bb0cf8259421b79f9a34222b" + + "0", + 123, + "0x5409ed021d9299bf6814279a6a1411a7e866a631", + ) + + +def test_is_valid_signature__signature_not_hex_string(): + """Test that passing a non-hex-string signature raises a ValueError.""" + with pytest.raises(ValueError): + is_valid_signature( + Web3.HTTPProvider("http://127.0.0.1:8545"), + "0x6927e990021d23b1eb7b8789f6a6feaf98fe104bb0cf8259421b79f9a34222b" + + "0", + "jjj", + "0x5409ed021d9299bf6814279a6a1411a7e866a631", + ) + + +def test_is_valid_signature__signer_address_not_string(): + """Test that giving a non-address `signer_address` raises a ValueError.""" + with pytest.raises(TypeError): + is_valid_signature( + Web3.HTTPProvider("http://127.0.0.1:8545"), + "0x6927e990021d23b1eb7b8789f6a6feaf98fe104bb0cf8259421b79f9a34222b" + + "0", + "0x1B61a3ed31b43c8780e905a260a35faefcc527be7516aa11c0256729b5b351b" + + "c3340349190569279751135161d22529dc25add4f6069af05be04cacbda2ace" + + "225403", + 123, + ) + + +def test_is_valid_signature__signer_address_not_hex_string(): + """Test that giving a non-hex-str `signer_address` raises a ValueError.""" + with pytest.raises(ValueError): + is_valid_signature( + Web3.HTTPProvider("http://127.0.0.1:8545"), + "0x6927e990021d23b1eb7b8789f6a6feaf98fe104bb0cf8259421b79f9a34222b" + + "0", + "0x1B61a3ed31b43c8780e905a260a35faefcc527be7516aa11c0256729b5b351b" + + "c3340349190569279751135161d22529dc25add4f6069af05be04cacbda2ace" + + "225403", + "jjj", + ) + + +def test_is_valid_signature__signer_address_not_valid_address(): + """Test that giving a non-address for `signer_address` raises an error.""" + with pytest.raises(ValueError): + is_valid_signature( + Web3.HTTPProvider("http://127.0.0.1:8545"), + "0x6927e990021d23b1eb7b8789f6a6feaf98fe104bb0cf8259421b79f9a34222b" + + "0", + "0x1B61a3ed31b43c8780e905a260a35faefcc527be7516aa11c0256729b5b351b" + + "c3340349190569279751135161d22529dc25add4f6069af05be04cacbda2ace" + + "225403", + "0xff", + ) + + +def test_is_valid_signature__unsupported_sig_types(): + """Test that passing in a sig w/invalid type raises error. + + To induce this error, the last byte of the signature is tweaked from 03 to + ff.""" + (is_valid, reason) = is_valid_signature( + Web3.HTTPProvider("http://127.0.0.1:8545"), + "0x6927e990021d23b1eb7b8789f6a6feaf98fe104bb0cf8259421b79f9a34222b0", + "0x1B61a3ed31b43c8780e905a260a35faefcc527be7516aa11c0256729b5b351bc334" + + "0349190569279751135161d22529dc25add4f6069af05be04cacbda2ace2254ff", + "0x5409ed021d9299bf6814279a6a1411a7e866a631", + ) + assert is_valid is False + assert reason == "SIGNATURE_UNSUPPORTED" diff --git a/python-packages/order_utils/tox.ini b/python-packages/order_utils/tox.ini new file mode 100644 index 000000000..ba7d55b56 --- /dev/null +++ b/python-packages/order_utils/tox.ini @@ -0,0 +1,25 @@ +# tox (https://tox.readthedocs.io/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +envlist = py37 + +[testenv] +commands = + pip install -e .[dev] + python setup.py test + +[testenv:run_tests_against_test_deployment] +commands = + # install dependencies from real PyPI + pip install eth-abi mypy_extensions web3 pytest + # install package-under-test from test PyPI + pip install --index-url https://test.pypi.org/legacy/ 0x-order-utils + pytest test + +[testenv:run_tests_against_deployment] +commands = + pip install 0x-order-utils + pytest test |