From 95b2898b9c0898c7e2d98ee603bff0604bf2a829 Mon Sep 17 00:00:00 2001 From: "F. Eugene Aumson" Date: Wed, 7 Nov 2018 11:20:46 -0500 Subject: [order_utils.py] is_signature_valid, via Exchange contract (#1216) First support for signature validation, done via Exchange contract's isValidSignature() method. --- python-packages/order_utils/README.md | 4 +- python-packages/order_utils/setup.py | 47 ++++++-- python-packages/order_utils/src/conf.py | 3 +- python-packages/order_utils/src/index.rst | 3 + .../src/zero_ex/contract_artifacts/__init__.py | 1 + .../src/zero_ex/contract_artifacts/artifacts | 1 + .../src/zero_ex/dev_utils/type_assertions.py | 10 ++ .../src/zero_ex/order_utils/__init__.py | 12 +- .../order_utils/src/zero_ex/order_utils/py.typed | 0 .../src/zero_ex/order_utils/signature_utils.py | 88 ++++++++++++++ python-packages/order_utils/src/zero_ex/py.typed | 0 .../order_utils/stubs/setuptools/__init__.pyi | 4 +- .../order_utils/stubs/web3/__init__.pyi | 18 ++- .../order_utils/stubs/web3/exceptions.pyi | 2 + .../order_utils/stubs/web3/utils/__init__.pyi | 0 .../order_utils/stubs/web3/utils/datatypes.pyi | 3 + python-packages/order_utils/test/test_doctest.py | 32 +++--- .../order_utils/test/test_signature_utils.py | 128 +++++++++++++++++++++ 18 files changed, 323 insertions(+), 33 deletions(-) create mode 100644 python-packages/order_utils/src/zero_ex/contract_artifacts/__init__.py create mode 120000 python-packages/order_utils/src/zero_ex/contract_artifacts/artifacts create mode 100644 python-packages/order_utils/src/zero_ex/order_utils/py.typed create mode 100644 python-packages/order_utils/src/zero_ex/order_utils/signature_utils.py delete mode 100644 python-packages/order_utils/src/zero_ex/py.typed create mode 100644 python-packages/order_utils/stubs/web3/exceptions.pyi create mode 100644 python-packages/order_utils/stubs/web3/utils/__init__.pyi create mode 100644 python-packages/order_utils/stubs/web3/utils/datatypes.pyi create mode 100644 python-packages/order_utils/test/test_signature_utils.py (limited to 'python-packages/order_utils') diff --git a/python-packages/order_utils/README.md b/python-packages/order_utils/README.md index a266694db..4c5f7627c 100644 --- a/python-packages/order_utils/README.md +++ b/python-packages/order_utils/README.md @@ -18,7 +18,7 @@ Please read our [contribution guidelines](../../CONTRIBUTING.md) before getting ### Install Code and Dependencies -Ensure that you have Python >=3.6 installed, then: +Ensure that you have installed Python >=3.6 and Docker. Then: ```bash pip install -e .[dev] @@ -26,7 +26,7 @@ pip install -e .[dev] ### Test -`./setup.py 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 diff --git a/python-packages/order_utils/setup.py b/python-packages/order_utils/setup.py index 22a5f4c41..1b07b612c 100755 --- a/python-packages/order_utils/setup.py +++ b/python-packages/order_utils/setup.py @@ -5,11 +5,12 @@ 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 setup +from setuptools import find_packages, setup from setuptools.command.test import test as TestCommand @@ -59,8 +60,15 @@ class LintCommand(distutils.command.build_py.build_py): import eth_abi eth_abi_dir = path.dirname(path.realpath(eth_abi.__file__)) - with open(path.join(eth_abi_dir, "py.typed"), "a"): - pass + 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( @@ -79,7 +87,7 @@ class CleanCommandExtension(clean): rmtree(".mypy_cache", ignore_errors=True) rmtree(".tox", ignore_errors=True) rmtree(".pytest_cache", ignore_errors=True) - rmtree("src/order_utils.egg-info", ignore_errors=True) + rmtree("src/0x_order_utils.egg-info", ignore_errors=True) # pylint: disable=too-many-ancestors @@ -111,6 +119,26 @@ class PublishCommand(distutils.command.build_py.build_py): 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() @@ -130,9 +158,9 @@ setup( "test": TestCommandExtension, "test_publish": TestPublishCommand, "publish": PublishCommand, + "ganache": GanacheCommand, }, - include_package_data=True, - install_requires=["eth-abi", "mypy_extensions", "web3"], + install_requires=["eth-abi", "eth_utils", "mypy_extensions", "web3"], extras_require={ "dev": [ "bandit", @@ -151,14 +179,17 @@ setup( ] }, python_requires=">=3.6, <4", - package_data={"zero_ex.order_utils": ["py.typed"]}, + 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=["zero_ex.order_utils", "zero_ex.dev_utils"], + packages=find_packages("src"), classifiers=[ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", diff --git a/python-packages/order_utils/src/conf.py b/python-packages/order_utils/src/conf.py index 606cd3b2a..6b6776d01 100644 --- a/python-packages/order_utils/src/conf.py +++ b/python-packages/order_utils/src/conf.py @@ -3,6 +3,7 @@ # Reference: http://www.sphinx-doc.org/en/master/config from typing import List +import pkg_resources # pylint: disable=invalid-name @@ -12,7 +13,7 @@ project = "0x-order-utils" # pylint: disable=redefined-builtin copyright = "2018, ZeroEx, Intl." author = "F. Eugene Aumson" -version = "0.1.0" # The short X.Y version +version = pkg_resources.get_distribution("0x-order-utils").version release = "" # The full version, including alpha/beta/rc tags extensions = [ diff --git a/python-packages/order_utils/src/index.rst b/python-packages/order_utils/src/index.rst index 22d5b0ef9..b99addabd 100644 --- a/python-packages/order_utils/src/index.rst +++ b/python-packages/order_utils/src/index.rst @@ -19,6 +19,9 @@ Python zero_ex.order_utils 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 ================== 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/type_assertions.py b/python-packages/order_utils/src/zero_ex/dev_utils/type_assertions.py index a100da567..08c1b0ea5 100644 --- 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 @@ -46,3 +46,13 @@ def assert_is_int(value: Any, name: str) -> None: 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 index f014af0f6..80445cb6e 100644 --- a/python-packages/order_utils/src/zero_ex/order_utils/__init__.py +++ b/python-packages/order_utils/src/zero_ex/order_utils/__init__.py @@ -1 +1,11 @@ -"""Order utilities for 0x applications.""" +"""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 `_ 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/py.typed b/python-packages/order_utils/src/zero_ex/order_utils/py.typed new file mode 100644 index 000000000..e69de29bb 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/src/zero_ex/py.typed b/python-packages/order_utils/src/zero_ex/py.typed deleted file mode 100644 index e69de29bb..000000000 diff --git a/python-packages/order_utils/stubs/setuptools/__init__.pyi b/python-packages/order_utils/stubs/setuptools/__init__.pyi index baa349d70..8ea8d32b7 100644 --- a/python-packages/order_utils/stubs/setuptools/__init__.pyi +++ b/python-packages/order_utils/stubs/setuptools/__init__.pyi @@ -1,6 +1,8 @@ from distutils.dist import Distribution -from typing import Any +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/web3/__init__.pyi b/python-packages/order_utils/stubs/web3/__init__.pyi index c6f357009..fcecc7434 100644 --- a/python-packages/order_utils/stubs/web3/__init__.pyi +++ b/python-packages/order_utils/stubs/web3/__init__.pyi @@ -1,10 +1,26 @@ -from typing import Optional, Union +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 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/test_doctest.py b/python-packages/order_utils/test/test_doctest.py index ba5da5418..2b0350ac0 100644 --- a/python-packages/order_utils/test/test_doctest.py +++ b/python-packages/order_utils/test/test_doctest.py @@ -1,24 +1,18 @@ -"""Exercise doctests for order_utils module.""" +"""Exercise doctests for all of our modules.""" from doctest import testmod +import pkgutil -from zero_ex.dev_utils import abi_utils, type_assertions -from zero_ex.order_utils import asset_data_utils +import zero_ex -def test_doctest_asset_data_utils(): - """Invoke doctest on the asset_data_utils module.""" - (failure_count, _) = testmod(asset_data_utils) - assert failure_count == 0 - - -def test_doctest_abi_utils(): - """Invoke doctest on the abi_utils module.""" - (failure_count, _) = testmod(abi_utils) - assert failure_count == 0 - - -def test_doctest_type_assertions(): - """Invoke doctest on the type_assertions module.""" - (failure_count, _) = testmod(type_assertions) - assert failure_count == 0 +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" -- cgit v1.2.3