diff options
Diffstat (limited to 'python-packages/order_utils/src')
15 files changed, 888 insertions, 0 deletions
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..551487ab1 --- /dev/null +++ b/python-packages/order_utils/src/index.rst @@ -0,0 +1,33 @@ +.. source for the sphinx-generated build/docs/web/index.html + +Python zero_ex.order_utils +========================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +.. autoclass:: zero_ex.order_utils.Order + :members: + +See source for class properties. Sphinx does not easily generate class property docs; pull requests welcome. + +.. 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 + +.. automodule:: zero_ex.json_schemas + :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..3fec775b0 --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/dev_utils/abi_utils.py @@ -0,0 +1,101 @@ +"""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 web3 import Web3 +from eth_abi import encode_abi + +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: + 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..1dcfb39a9 --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/dev_utils/type_assertions.py @@ -0,0 +1,89 @@ +"""Assertions for runtime type checking of function arguments.""" + +from typing import Any + +from eth_utils import is_address +from web3.providers.base import BaseProvider + + +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 + + +def assert_is_address(value: Any, name: str) -> None: + """Assert that `value` is a valid Ethereum address. + + If `value` isn't a hex string, raise a TypeError. If it isn't a valid + Ethereum address, raise a ValueError. + """ + assert_is_hex_string(value, name) + if not is_address(value): + raise ValueError( + f"Expected variable '{name}' to be a valid Ethereum" + + " address, but it's not." + ) + + +def assert_is_provider(value: Any, name: str) -> None: + """Assert that `value` is a Web3 provider. + + If `value` isn't a Web3 provider, raise a TypeError. + """ + # TODO: make this provider check more flexible. + # https://app.asana.com/0/684263176955174/901300863045491/f + if not isinstance(value, BaseProvider): + raise TypeError( + f"Expected variable '{name}' to be an instance of a Web3 provider," + + " but it's not." + ) diff --git a/python-packages/order_utils/src/zero_ex/json_schemas/__init__.py b/python-packages/order_utils/src/zero_ex/json_schemas/__init__.py new file mode 100644 index 000000000..2a1728b8a --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/json_schemas/__init__.py @@ -0,0 +1,61 @@ +"""JSON schemas and associated utilities.""" + +from os import path +import json +from typing import Mapping + +from pkg_resources import resource_string +import jsonschema + + +def assert_valid(data: Mapping, schema_id: str) -> None: + """Validate the given `data` against the specified `schema`. + + :param data: Python dictionary to be validated as a JSON object. + :param schema_id: id property of the JSON schema to validate against. Must + be one of those listed in `the 0x JSON schema files + <https://github.com/0xProject/0x-monorepo/tree/development/packages/json-schemas/schemas>`_. + + Raises an exception if validation fails. + + >>> assert_valid( + ... {'v': 27, 'r': '0x'+'f'*64, 's': '0x'+'f'*64}, + ... '/ECSignature', + ... ) + """ + # noqa + class LocalRefResolver(jsonschema.RefResolver): + """Resolve package-local JSON schema id's.""" + + def __init__(self): + self.ref_to_file = { + "/addressSchema": "address_schema.json", + "/hexSchema": "hex_schema.json", + "/orderSchema": "order_schema.json", + "/wholeNumberSchema": "whole_number_schema.json", + "/ECSignature": "ec_signature_schema.json", + "/ecSignatureParameterSchema": ( + "ec_signature_parameter_schema.json" + "" + ), + } + jsonschema.RefResolver.__init__(self, "", "") + + def resolve_from_url(self, url): + """Resolve the given URL.""" + ref = url.replace("file://", "") + if ref in self.ref_to_file: + return json.loads( + resource_string( + "zero_ex.json_schemas", + f"schemas/{self.ref_to_file[ref]}", + ) + ) + raise jsonschema.ValidationError( + f"Unknown ref '{ref}'. " + + f"Known refs: {list(self.ref_to_file.keys())}." + ) + + resolver = LocalRefResolver() + jsonschema.validate( + data, resolver.resolve_from_url(schema_id), resolver=resolver + ) diff --git a/python-packages/order_utils/src/zero_ex/json_schemas/schemas b/python-packages/order_utils/src/zero_ex/json_schemas/schemas new file mode 120000 index 000000000..b8257372c --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/json_schemas/schemas @@ -0,0 +1 @@ +../../../../../packages/json-schemas/schemas/
\ No newline at end of file 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..24c6bfd4e --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/order_utils/__init__.py @@ -0,0 +1,405 @@ +"""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"``. +""" + +from enum import auto, Enum +import json +from typing import Dict, Tuple +from pkg_resources import resource_string + +from mypy_extensions import TypedDict + +from eth_utils import keccak, to_bytes, to_checksum_address +from web3 import Web3 +import web3.exceptions +from web3.providers.base import BaseProvider +from web3.utils import datatypes + +from zero_ex.dev_utils.type_assertions import ( + assert_is_address, + assert_is_hex_string, + assert_is_provider, +) +from zero_ex.json_schemas import assert_valid + + +class _Constants: + """Static data used by order utilities.""" + + _contract_name_to_abi: Dict[str, Dict] = {} # class data, not instance + + @classmethod + def contract_name_to_abi(cls, contract_name: str) -> Dict: + """Return the ABI for the given contract name. + + First tries to get data from the class level storage + `_contract_name_to_abi`. If it's not there, loads it from disk, stores + it in the class data (for the next caller), and then returns it. + """ + try: + return cls._contract_name_to_abi[contract_name] + except KeyError: + cls._contract_name_to_abi[contract_name] = json.loads( + resource_string( + "zero_ex.contract_artifacts", + f"artifacts/{contract_name}.json", + ) + )["compilerOutput"]["abi"] + return cls._contract_name_to_abi[contract_name] + + network_to_exchange_addr: Dict[str, str] = { + "1": "0x4f833a24e1f95d70f028921e27040ca56e09ab0b", + "3": "0x4530c0483a1633c7a1c97d2c53721caff2caaaaf", + "42": "0x35dd2932454449b14cee11a94d3674a936d5d7b2", + "50": "0x48bacb9266a570d521063ef5dd96e61686dbe788", + } + + null_address = "0x0000000000000000000000000000000000000000" + + eip191_header = b"\x19\x01" + + eip712_domain_separator_schema_hash = keccak( + b"EIP712Domain(string name,string version,address verifyingContract)" + ) + + eip712_domain_struct_header = ( + eip712_domain_separator_schema_hash + + keccak(b"0x Protocol") + + keccak(b"2") + ) + + eip712_order_schema_hash = keccak( + b"Order(" + + b"address makerAddress," + + b"address takerAddress," + + b"address feeRecipientAddress," + + b"address senderAddress," + + b"uint256 makerAssetAmount," + + b"uint256 takerAssetAmount," + + b"uint256 makerFee," + + b"uint256 takerFee," + + b"uint256 expirationTimeSeconds," + + b"uint256 salt," + + b"bytes makerAssetData," + + b"bytes takerAssetData" + + b")" + ) + + class SignatureType(Enum): + """Enumeration of known signature types.""" + + ILLEGAL = 0 + INVALID = auto() + EIP712 = auto() + ETH_SIGN = auto() + WALLET = auto() + VALIDATOR = auto() + PRE_SIGNED = auto() + N_SIGNATURE_TYPES = auto() + + +class Order(TypedDict): # pylint: disable=too-many-instance-attributes + """Object representation of a 0x order.""" + + makerAddress: str + takerAddress: str + feeRecipientAddress: str + senderAddress: str + makerAssetAmount: str + takerAssetAmount: str + makerFee: str + takerFee: str + expirationTimeSeconds: str + salt: str + makerAssetData: str + takerAssetData: str + exchangeAddress: str + + +def make_empty_order() -> Order: + """Construct an empty order. + + Initializes all strings to "0x0000000000000000000000000000000000000000" + and all numbers to 0. + """ + return { + "makerAddress": _Constants.null_address, + "takerAddress": _Constants.null_address, + "senderAddress": _Constants.null_address, + "feeRecipientAddress": _Constants.null_address, + "makerAssetData": _Constants.null_address, + "takerAssetData": _Constants.null_address, + "salt": "0", + "makerFee": "0", + "takerFee": "0", + "makerAssetAmount": "0", + "takerAssetAmount": "0", + "expirationTimeSeconds": "0", + "exchangeAddress": _Constants.null_address, + } + + +def generate_order_hash_hex(order: Order) -> str: + """Calculate the hash of the given order as a hexadecimal string. + + :param order: The order to be hashed. Must conform to `the 0x order JSON schema <https://github.com/0xProject/0x-monorepo/blob/development/packages/json-schemas/schemas/order_schema.json>`_. + :param exchange_address: The address to which the 0x Exchange smart + contract has been deployed. + :rtype: A string, of ASCII hex digits, representing the order hash. + + >>> generate_order_hash_hex( + ... { + ... 'makerAddress': "0x0000000000000000000000000000000000000000", + ... 'takerAddress': "0x0000000000000000000000000000000000000000", + ... 'feeRecipientAddress': "0x0000000000000000000000000000000000000000", + ... 'senderAddress': "0x0000000000000000000000000000000000000000", + ... 'makerAssetAmount': "1000000000000000000", + ... 'takerAssetAmount': "1000000000000000000", + ... 'makerFee': "0", + ... 'takerFee': "0", + ... 'expirationTimeSeconds': "12345", + ... 'salt': "12345", + ... 'makerAssetData': "0x0000000000000000000000000000000000000000", + ... 'takerAssetData': "0x0000000000000000000000000000000000000000", + ... 'exchangeAddress': "0x0000000000000000000000000000000000000000", + ... }, + ... ) + '55eaa6ec02f3224d30873577e9ddd069a288c16d6fb407210eecbc501fa76692' + """ # noqa: E501 (line too long) + assert_valid(order, "/orderSchema") + + def pad_20_bytes_to_32(twenty_bytes: bytes): + return bytes(12) + twenty_bytes + + def int_to_32_big_endian_bytes(i: int): + return i.to_bytes(32, byteorder="big") + + eip712_domain_struct_hash = keccak( + _Constants.eip712_domain_struct_header + + pad_20_bytes_to_32(to_bytes(hexstr=order["exchangeAddress"])) + ) + + eip712_order_struct_hash = keccak( + _Constants.eip712_order_schema_hash + + pad_20_bytes_to_32(to_bytes(hexstr=order["makerAddress"])) + + pad_20_bytes_to_32(to_bytes(hexstr=order["takerAddress"])) + + pad_20_bytes_to_32(to_bytes(hexstr=order["feeRecipientAddress"])) + + pad_20_bytes_to_32(to_bytes(hexstr=order["senderAddress"])) + + int_to_32_big_endian_bytes(int(order["makerAssetAmount"])) + + int_to_32_big_endian_bytes(int(order["takerAssetAmount"])) + + int_to_32_big_endian_bytes(int(order["makerFee"])) + + int_to_32_big_endian_bytes(int(order["takerFee"])) + + int_to_32_big_endian_bytes(int(order["expirationTimeSeconds"])) + + int_to_32_big_endian_bytes(int(order["salt"])) + + keccak(to_bytes(hexstr=order["makerAssetData"])) + + keccak(to_bytes(hexstr=order["takerAssetData"])) + ) + + return keccak( + _Constants.eip191_header + + eip712_domain_struct_hash + + eip712_order_struct_hash + ).hex() + + +def is_valid_signature( + provider: BaseProvider, data: str, signature: str, signer_address: str +) -> Tuple[bool, str]: + """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: Tuple consisting of a boolean and a string. Boolean is true if + valid, false otherwise. If false, the string describes the reason. + + >>> is_valid_signature( + ... Web3.HTTPProvider("http://127.0.0.1:8545"), + ... '0x6927e990021d23b1eb7b8789f6a6feaf98fe104bb0cf8259421b79f9a34222b0', + ... '0x1B61a3ed31b43c8780e905a260a35faefcc527be7516aa11c0256729b5b351bc3340349190569279751135161d22529dc25add4f6069af05be04cacbda2ace225403', + ... '0x5409ed021d9299bf6814279a6a1411a7e866a631', + ... ) + (True, '') + """ # noqa: E501 (line too long) + assert_is_provider(provider, "provider") + assert_is_hex_string(data, "data") + assert_is_hex_string(signature, "signature") + assert_is_address(signer_address, "signer_address") + + web3_instance = Web3(provider) + # false positive from pylint: disable=no-member + network_id = web3_instance.net.version + contract_address = _Constants.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=_Constants.contract_name_to_abi("Exchange"), + ) + 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_ILLEGAL", + "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}") + + +class ECSignature(TypedDict): + """Object representation of an elliptic curve signature's parameters.""" + + v: int + r: str + s: str + + +def _parse_signature_hex_as_vrs(signature_hex: str) -> ECSignature: + """Parse signature hex as a concatentation of EC parameters ordered V, R, S. + + >>> _parse_signature_hex_as_vrs('0x1b117902c86dfb95fe0d1badd983ee166ad259b27acb220174cbb4460d872871137feabdfe76e05924b484789f79af4ee7fa29ec006cedce1bbf369320d034e10b03') + {'v': 27, 'r': '117902c86dfb95fe0d1badd983ee166ad259b27acb220174cbb4460d87287113', 's': '7feabdfe76e05924b484789f79af4ee7fa29ec006cedce1bbf369320d034e10b'} + """ # noqa: E501 (line too long) + signature: ECSignature = { + "v": int(signature_hex[2:4], 16), + "r": signature_hex[4:68], + "s": signature_hex[68:132], + } + if signature["v"] == 0 or signature["v"] == 1: + signature["v"] = signature["v"] + 27 + return signature + + +def _parse_signature_hex_as_rsv(signature_hex: str) -> ECSignature: + """Parse signature hex as a concatentation of EC parameters ordered R, S, V. + + >>> _parse_signature_hex_as_rsv('0x117902c86dfb95fe0d1badd983ee166ad259b27acb220174cbb4460d872871137feabdfe76e05924b484789f79af4ee7fa29ec006cedce1bbf369320d034e10b00') + {'r': '117902c86dfb95fe0d1badd983ee166ad259b27acb220174cbb4460d87287113', 's': '7feabdfe76e05924b484789f79af4ee7fa29ec006cedce1bbf369320d034e10b', 'v': 27} + """ # noqa: E501 (line too long) + signature: ECSignature = { + "r": signature_hex[2:66], + "s": signature_hex[66:130], + "v": int(signature_hex[130:132], 16), + } + if signature["v"] == 0 or signature["v"] == 1: + signature["v"] = signature["v"] + 27 + return signature + + +def _convert_ec_signature_to_vrs_hex(signature: ECSignature) -> str: + """Convert elliptic curve signature object to hex hash string. + + >>> _convert_ec_signature_to_vrs_hex( + ... { + ... 'r': '117902c86dfb95fe0d1badd983ee166ad259b27acb220174cbb4460d87287113', + ... 's': '7feabdfe76e05924b484789f79af4ee7fa29ec006cedce1bbf369320d034e10b', + ... 'v': 27 + ... } + ... ) + '0x1b117902c86dfb95fe0d1badd983ee166ad259b27acb220174cbb4460d872871137feabdfe76e05924b484789f79af4ee7fa29ec006cedce1bbf369320d034e10b' + """ # noqa: E501 (line too long) + return ( + "0x" + + signature["v"].to_bytes(1, byteorder="big").hex() + + signature["r"] + + signature["s"] + ) + + +def sign_hash( + provider: BaseProvider, signer_address: str, hash_hex: str +) -> str: + """Sign a message with the given hash, and return the signature. + + :param provider: A Web3 provider. + :param signer_address: The address of the signing account. + :param hash_hex: A hex string representing the hash, like that returned + from `generate_order_hash_hex()`. + :rtype: A string, of ASCII hex digits, representing the signature. + + >>> provider = Web3.HTTPProvider("http://127.0.0.1:8545") + >>> sign_hash( + ... provider, + ... Web3(provider).personal.listAccounts[0], + ... '0x34decbedc118904df65f379a175bb39ca18209d6ce41d5ed549d54e6e0a95004', + ... ) + '0x1b117902c86dfb95fe0d1badd983ee166ad259b27acb220174cbb4460d872871137feabdfe76e05924b484789f79af4ee7fa29ec006cedce1bbf369320d034e10b03' + """ # noqa: E501 (line too long) + assert_is_provider(provider, "provider") + assert_is_address(signer_address, "signer_address") + assert_is_hex_string(hash_hex, "hash_hex") + + web3_instance = Web3(provider) + # false positive from pylint: disable=no-member + signature = web3_instance.eth.sign( # type: ignore + signer_address, hexstr=hash_hex.replace("0x", "") + ).hex() + + valid_v_param_values = [27, 28] + + # HACK: There is no consensus on whether the signatureHex string should be + # formatted as v + r + s OR r + s + v, and different clients (even + # different versions of the same client) return the signature params in + # different orders. In order to support all client implementations, we + # parse the signature in both ways, and evaluate if either one is a valid + # signature. r + s + v is the most prevalent format from eth_sign, so we + # attempt this first. + + ec_signature = _parse_signature_hex_as_rsv(signature) + if ec_signature["v"] in valid_v_param_values: + signature_as_vrst_hex = ( + _convert_ec_signature_to_vrs_hex(ec_signature) + + _Constants.SignatureType.ETH_SIGN.value.to_bytes( + 1, byteorder="big" + ).hex() + ) + + (valid, _) = is_valid_signature( + provider, hash_hex, signature_as_vrst_hex, signer_address + ) + + if valid is True: + return signature_as_vrst_hex + + ec_signature = _parse_signature_hex_as_vrs(signature) + if ec_signature["v"] in valid_v_param_values: + signature_as_vrst_hex = ( + _convert_ec_signature_to_vrs_hex(ec_signature) + + _Constants.SignatureType.ETH_SIGN.value.to_bytes( + 1, byteorder="big" + ).hex() + ) + (valid, _) = is_valid_signature( + provider, hash_hex, signature_as_vrst_hex, signer_address + ) + + if valid is True: + return signature_as_vrst_hex + + raise RuntimeError( + "Signature returned from web3 provider is in an unknown format." + + " Attempted to parse as RSV and as VRS." + ) 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..fab7479d2 --- /dev/null +++ b/python-packages/order_utils/src/zero_ex/order_utils/asset_data_utils.py @@ -0,0 +1,139 @@ +"""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: + """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: + """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: + """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] + 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 |