Python contract demo, with lots of refactoring (#1485)

* Refine Order for Web3 compat. & add conversions

Changed some of the fields in the Order class so that it can be passed
to our contracts via Web3.

Added conversion utilities so that an Order can be easily converted to
and from a JSON-compatible dict (specifically by encoding/decoding the
`bytes` fields), to facilitate validation against the JSON schema.

Also modified JSON order schema to accept integers in addition to
stringified integers.

* Fixes for json_schemas

Has-types indicator file, py.typed, was not being included in package.

Schemas were not being properly gathered into package installation.

* Add test/demo of Exchange.getOrderInfo()

* web3 bug workaround

* Fix problem packaging contract artifacts

* Move contract addresses to their own package

* Move contract artifacts to their own package

* Add scripts to install, test & lint all components

* prettierignore files in local python dev env

* Correct missing coverage analysis for sra_client

* CI cache lint: don't save, re-use from test-python

* tag hacks as hacks

* correct merge mistake

* remove local strip_0x() in favor of eth_utils

* remove json schemas from old order_utils location

* correct merge mistake

* doctest json schemas via command-line, not code
This commit is contained in:
F. Eugene Aumson
2019-01-09 09:58:29 -05:00
committed by GitHub
parent 5b7eff217e
commit aa5af04447
136 changed files with 8274 additions and 178 deletions

View File

@@ -21,7 +21,7 @@ class TestCommandExtension(TestCommand):
"""Invoke pytest."""
import pytest
exit(pytest.main())
exit(pytest.main(["--doctest-modules"]))
class LintCommand(distutils.command.build_py.build_py):
@@ -165,9 +165,13 @@ setup(
"ganache": GanacheCommand,
},
install_requires=[
"0x-contract-addresses",
"0x-contract-artifacts",
"0x-json-schemas",
"eth-abi",
"eth_utils",
"hypothesis>=3.31.2", # HACK! this is web3's dependency!
# above works around https://github.com/ethereum/web3.py/issues/1179
"mypy_extensions",
"web3",
],
@@ -189,10 +193,7 @@ setup(
]
},
python_requires=">=3.6, <4",
package_data={
"zero_ex.order_utils": ["py.typed"],
"zero_ex.contract_artifacts": ["artifacts/*"],
},
package_data={"zero_ex.order_utils": ["py.typed"]},
package_dir={"": "src"},
license="Apache 2.0",
keywords=(

View File

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

View File

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

View File

@@ -10,19 +10,22 @@ just this purpose. To start it: ``docker run -d -p 8545:8545 0xorg/ganache-cli
fence smart topic"``.
"""
from copy import copy
from enum import auto, Enum
import json
from typing import Dict, Tuple
from typing import cast, Dict, NamedTuple, Tuple
from pkg_resources import resource_string
from mypy_extensions import TypedDict
from eth_utils import keccak, to_bytes, to_checksum_address
from eth_utils import keccak, remove_0x_prefix, 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.contract_addresses import NETWORK_TO_ADDRESSES, NetworkId
import zero_ex.contract_artifacts
from zero_ex.dev_utils.type_assertions import (
assert_is_address,
assert_is_hex_string,
@@ -34,34 +37,6 @@ 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"
@@ -107,47 +82,153 @@ class _Constants:
class Order(TypedDict): # pylint: disable=too-many-instance-attributes
"""Object representation of a 0x order."""
"""A Web3-compatible representation of the Exchange.Order struct."""
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
makerAssetAmount: int
takerAssetAmount: int
makerFee: int
takerFee: int
expirationTimeSeconds: int
salt: int
makerAssetData: bytes
takerAssetData: bytes
def make_empty_order() -> Order:
"""Construct an empty order.
Initializes all strings to "0x0000000000000000000000000000000000000000"
and all numbers to 0.
Initializes all strings to "0x0000000000000000000000000000000000000000",
all numbers to 0, and all bytes to nulls.
"""
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,
"makerAssetData": (b"\x00") * 20,
"takerAssetData": (b"\x00") * 20,
"salt": 0,
"makerFee": 0,
"takerFee": 0,
"makerAssetAmount": 0,
"takerAssetAmount": 0,
"expirationTimeSeconds": 0,
}
def generate_order_hash_hex(order: Order) -> str:
def order_to_jsdict(
order: Order, exchange_address="0x0000000000000000000000000000000000000000"
) -> dict:
"""Convert a Web3-compatible order struct to a JSON-schema-compatible dict.
More specifically, do explicit decoding for the `bytes` fields.
>>> import pprint
>>> pprint.pprint(order_to_jsdict(
... {
... 'makerAddress': "0x0000000000000000000000000000000000000000",
... 'takerAddress': "0x0000000000000000000000000000000000000000",
... 'feeRecipientAddress':
... "0x0000000000000000000000000000000000000000",
... 'senderAddress': "0x0000000000000000000000000000000000000000",
... 'makerAssetAmount': 1,
... 'takerAssetAmount': 1,
... 'makerFee': 0,
... 'takerFee': 0,
... 'expirationTimeSeconds': 1,
... 'salt': 1,
... 'makerAssetData': (0).to_bytes(1, byteorder='big') * 20,
... 'takerAssetData': (0).to_bytes(1, byteorder='big') * 20,
... },
... ))
{'exchangeAddress': '0x0000000000000000000000000000000000000000',
'expirationTimeSeconds': 1,
'feeRecipientAddress': '0x0000000000000000000000000000000000000000',
'makerAddress': '0x0000000000000000000000000000000000000000',
'makerAssetAmount': 1,
'makerAssetData': '0x0000000000000000000000000000000000000000',
'makerFee': 0,
'salt': 1,
'senderAddress': '0x0000000000000000000000000000000000000000',
'takerAddress': '0x0000000000000000000000000000000000000000',
'takerAssetAmount': 1,
'takerAssetData': '0x0000000000000000000000000000000000000000',
'takerFee': 0}
"""
jsdict = cast(Dict, copy(order))
# encode bytes fields
jsdict["makerAssetData"] = "0x" + order["makerAssetData"].hex()
jsdict["takerAssetData"] = "0x" + order["takerAssetData"].hex()
jsdict["exchangeAddress"] = exchange_address
assert_valid(jsdict, "/orderSchema")
return jsdict
def jsdict_order_to_struct(jsdict: dict) -> Order:
r"""Convert a JSON-schema-compatible dict order to a Web3-compatible struct.
More specifically, do explicit encoding of the `bytes` fields.
>>> import pprint
>>> pprint.pprint(jsdict_order_to_struct(
... {
... '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",
... },
... ))
{'expirationTimeSeconds': 12345,
'feeRecipientAddress': '0x0000000000000000000000000000000000000000',
'makerAddress': '0x0000000000000000000000000000000000000000',
'makerAssetAmount': 1000000000000000000,
'makerAssetData': b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
b'\x00\x00\x00\x00\x00\x00\x00\x00',
'makerFee': 0,
'salt': 12345,
'senderAddress': '0x0000000000000000000000000000000000000000',
'takerAddress': '0x0000000000000000000000000000000000000000',
'takerAssetAmount': 1000000000000000000,
'takerAssetData': b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
b'\x00\x00\x00\x00\x00\x00\x00\x00',
'takerFee': 0}
""" # noqa: E501 (line too long)
assert_valid(jsdict, "/orderSchema")
order = cast(Order, copy(jsdict))
order["makerAssetData"] = bytes.fromhex(
remove_0x_prefix(jsdict["makerAssetData"])
)
order["takerAssetData"] = bytes.fromhex(
remove_0x_prefix(jsdict["takerAssetData"])
)
del order["exchangeAddress"] # type: ignore
# silence mypy pending release of
# https://github.com/python/mypy/issues/3550
return order
def generate_order_hash_hex(order: Order, exchange_address: str) -> 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>`_.
@@ -167,14 +248,15 @@ def generate_order_hash_hex(order: Order) -> str:
... 'takerFee': "0",
... 'expirationTimeSeconds': "12345",
... 'salt': "12345",
... 'makerAssetData': "0x0000000000000000000000000000000000000000",
... 'takerAssetData': "0x0000000000000000000000000000000000000000",
... 'exchangeAddress': "0x0000000000000000000000000000000000000000",
... 'makerAssetData': (0).to_bytes(1, byteorder='big') * 20,
... 'takerAssetData': (0).to_bytes(1, byteorder='big') * 20,
... },
... exchange_address="0x0000000000000000000000000000000000000000",
... )
'55eaa6ec02f3224d30873577e9ddd069a288c16d6fb407210eecbc501fa76692'
""" # noqa: E501 (line too long)
assert_valid(order, "/orderSchema")
assert_is_address(exchange_address, "exchange_address")
assert_valid(order_to_jsdict(order, exchange_address), "/orderSchema")
def pad_20_bytes_to_32(twenty_bytes: bytes):
return bytes(12) + twenty_bytes
@@ -184,7 +266,7 @@ def generate_order_hash_hex(order: Order) -> str:
eip712_domain_struct_hash = keccak(
_Constants.eip712_domain_struct_header
+ pad_20_bytes_to_32(to_bytes(hexstr=order["exchangeAddress"]))
+ pad_20_bytes_to_32(to_bytes(hexstr=exchange_address))
)
eip712_order_struct_hash = keccak(
@@ -199,8 +281,8 @@ def generate_order_hash_hex(order: Order) -> str:
+ 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"]))
+ keccak(to_bytes(hexstr=order["makerAssetData"].hex()))
+ keccak(to_bytes(hexstr=order["takerAssetData"].hex()))
)
return keccak(
@@ -210,6 +292,14 @@ def generate_order_hash_hex(order: Order) -> str:
).hex()
class OrderInfo(NamedTuple):
"""A Web3-compatible representation of the Exchange.OrderInfo struct."""
order_status: str
order_hash: bytes
order_taker_asset_filled_amount: int
def is_valid_signature(
provider: BaseProvider, data: str, signature: str, signer_address: str
) -> Tuple[bool, str]:
@@ -241,12 +331,13 @@ def is_valid_signature(
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]
contract_address = NETWORK_TO_ADDRESSES[
NetworkId(int(web3_instance.net.version))
].exchange
# 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"),
abi=zero_ex.contract_artifacts.abi_by_name("Exchange"),
)
try:
return (

View File

@@ -1,18 +0,0 @@
"""Exercise doctests for all of our modules."""
from doctest import testmod
import pkgutil
import importlib
import zero_ex
def test_all_doctests():
"""Gather zero_ex.* modules and doctest them."""
for (_, modname, _) in pkgutil.walk_packages(
path=zero_ex.__path__, prefix="zero_ex."
):
module = importlib.import_module(modname)
print(module)
(failure_count, _) = testmod(module)
assert failure_count == 0

View File

@@ -8,5 +8,7 @@ def test_get_order_hash_hex__empty_order():
expected_hash_hex = (
"faa49b35faeb9197e9c3ba7a52075e6dad19739549f153b77dfcf59408a4b422"
)
actual_hash_hex = generate_order_hash_hex(make_empty_order())
actual_hash_hex = generate_order_hash_hex(
make_empty_order(), "0x0000000000000000000000000000000000000000"
)
assert actual_hash_hex == expected_hash_hex