[order_utils.py] is_signature_valid, via Exchange contract (#1216)

First support for signature validation, done via Exchange contract's
isValidSignature() method.
This commit is contained in:
F. Eugene Aumson 2018-11-07 11:20:46 -05:00 committed by GitHub
parent 094f710662
commit 95b2898b9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 328 additions and 33 deletions

View File

@ -162,6 +162,9 @@ jobs:
working_directory: ~/repo
docker:
- image: circleci/python
- image: 0xorg/ganache-cli
command: |
ganache-cli --gasLimit 10000000 --noVMErrorsOnRPCResponse --db /snapshot --noVMErrorsOnRPCResponse -p 8545 --networkId 50 -m "concert load couple harbor equip island argue ramp clarify fence smart topic"
steps:
- checkout
- run: sudo chown -R circleci:circleci /usr/local/bin

1
.gitignore vendored
View File

@ -99,6 +99,7 @@ packages/*/scripts/
.mypy_cache
.tox
python-packages/*/build
python-packages/*/dist
__pycache__
python-packages/*/src/*.egg-info
python-packages/*/.coverage

View File

@ -4,6 +4,7 @@ lib
/packages/contracts/generated-artifacts
/packages/abi-gen-wrappers/src/generated-wrappers
/packages/contract-artifacts/artifacts
/python-packages/order_utils/src/zero_ex/contract_artifacts/artifacts
/packages/json-schemas/schemas
/packages/metacoin/src/contract_wrappers
/packages/metacoin/artifacts

View File

@ -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

View File

@ -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",

View File

@ -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 = [

View File

@ -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
==================

View File

@ -0,0 +1 @@
"""Solc-generated artifacts for 0x smart contracts."""

View File

@ -0,0 +1 @@
../../../../../packages/contract-artifacts/artifacts

View File

@ -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

View File

@ -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 <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"``.
"""

View File

@ -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}")

View File

@ -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]: ...

View File

@ -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: ...
...
...

View File

@ -0,0 +1,2 @@
class BadFunctionCallOutput(Exception):
...

View File

@ -0,0 +1,3 @@
class Contract:
def call(self): ...
...

View File

@ -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

View File

@ -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"