From af91a56a5594d07d7da6caaeff79f5a7fb31ff98 Mon Sep 17 00:00:00 2001 From: "F. Eugene Aumson" Date: Fri, 26 Oct 2018 15:13:42 -0400 Subject: feat(order_utils.py): ERC721 asset data codec (#1186) --- packages/order-utils/test/asset_data_utils_test.ts | 25 ++++++- python-packages/order_utils/setup.py | 2 +- python-packages/order_utils/src/index.rst | 4 +- .../src/zero_ex/dev_utils/type_assertions.py | 15 +++++ .../src/zero_ex/order_utils/asset_data_utils.py | 77 +++++++++++++++++++++- .../order_utils/test/test_asset_data_utils.py | 39 ++++++++++- 6 files changed, 153 insertions(+), 9 deletions(-) diff --git a/packages/order-utils/test/asset_data_utils_test.ts b/packages/order-utils/test/asset_data_utils_test.ts index f8b850604..f175b7a38 100644 --- a/packages/order-utils/test/asset_data_utils_test.ts +++ b/packages/order-utils/test/asset_data_utils_test.ts @@ -1,6 +1,7 @@ import * as chai from 'chai'; -import { ERC20AssetData } from '@0x/types'; +import { ERC20AssetData, ERC721AssetData } from '@0x/types'; +import { BigNumber } from '@0x/utils'; import { assetDataUtils } from '../src/asset_data_utils'; @@ -14,18 +15,36 @@ const KNOWN_ENCODINGS = [ address: '0x1dc4c1cefef38a777b15aa20260a54e584b16c48', assetData: '0xf47261b00000000000000000000000001dc4c1cefef38a777b15aa20260a54e584b16c48', }, + { + address: '0x1dc4c1cefef38a777b15aa20260a54e584b16c48', + tokenId: new BigNumber(1), + assetData: + '0x025717920000000000000000000000001dc4c1cefef38a777b15aa20260a54e584b16c480000000000000000000000000000000000000000000000000000000000000001', + }, ]; const ERC20_ASSET_PROXY_ID = '0xf47261b0'; +const ERC721_ASSET_PROXY_ID = '0x02571792'; describe('assetDataUtils', () => { - it('should encode', () => { + it('should encode ERC20', () => { const assetData = assetDataUtils.encodeERC20AssetData(KNOWN_ENCODINGS[0].address); expect(assetData).to.equal(KNOWN_ENCODINGS[0].assetData); }); - it('should decode', () => { + it('should decode ERC20', () => { const assetData: ERC20AssetData = assetDataUtils.decodeERC20AssetData(KNOWN_ENCODINGS[0].assetData); expect(assetData.tokenAddress).to.equal(KNOWN_ENCODINGS[0].address); expect(assetData.assetProxyId).to.equal(ERC20_ASSET_PROXY_ID); }); + it('should encode ERC721', () => { + const assetData = assetDataUtils.encodeERC721AssetData(KNOWN_ENCODINGS[1].address, KNOWN_ENCODINGS[1] + .tokenId as BigNumber); + expect(assetData).to.equal(KNOWN_ENCODINGS[1].assetData); + }); + it('should decode ERC721', () => { + const assetData: ERC721AssetData = assetDataUtils.decodeERC721AssetData(KNOWN_ENCODINGS[1].assetData); + expect(assetData.tokenAddress).to.equal(KNOWN_ENCODINGS[1].address); + expect(assetData.assetProxyId).to.equal(ERC721_ASSET_PROXY_ID); + expect(assetData.tokenId).to.be.bignumber.equal(KNOWN_ENCODINGS[1].tokenId); + }); }); diff --git a/python-packages/order_utils/setup.py b/python-packages/order_utils/setup.py index 159499ec4..22a5f4c41 100755 --- a/python-packages/order_utils/setup.py +++ b/python-packages/order_utils/setup.py @@ -20,7 +20,7 @@ class TestCommandExtension(TestCommand): """Invoke pytest.""" import pytest - pytest.main() + exit(pytest.main()) # pylint: disable=too-many-ancestors diff --git a/python-packages/order_utils/src/index.rst b/python-packages/order_utils/src/index.rst index 9c26b7924..22d5b0ef9 100644 --- a/python-packages/order_utils/src/index.rst +++ b/python-packages/order_utils/src/index.rst @@ -15,7 +15,9 @@ Python zero_ex.order_utils .. autoclass:: zero_ex.order_utils.asset_data_utils.ERC20AssetData -See source for properties. Sphinx does not easily generate class property docs; pull requests welcome. +.. 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. Indices and tables ================== 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 745d014e6..a100da567 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 @@ -31,3 +31,18 @@ def assert_is_list(value: Any, name: str) -> None: 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__}'" + ) 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 index 451de39af..e6f9a07c1 100644 --- 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 @@ -5,10 +5,11 @@ 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 +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 @@ -19,6 +20,14 @@ class ERC20AssetData(TypedDict): 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. @@ -39,7 +48,7 @@ def encode_erc20_asset_data(token_address: str) -> str: def decode_erc20_asset_data(asset_data: str) -> ERC20AssetData: # docstring considered all one line by pylint: disable=line-too-long - """Decode an ERC20 assetData hex string. + """Decode an ERC20 asset data hex string. :param asset_data: String produced by prior call to encode_erc20_asset_data() @@ -55,7 +64,7 @@ def decode_erc20_asset_data(asset_data: str) -> ERC20AssetData: + f" Got {str(len(asset_data))}." ) - asset_proxy_id: str = asset_data[0:10] + 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" @@ -70,3 +79,65 @@ def decode_erc20_asset_data(asset_data: str) -> ERC20AssetData: )[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/test/test_asset_data_utils.py b/python-packages/order_utils/test/test_asset_data_utils.py index eeada5873..079368714 100644 --- a/python-packages/order_utils/test/test_asset_data_utils.py +++ b/python-packages/order_utils/test/test_asset_data_utils.py @@ -3,9 +3,12 @@ import pytest from zero_ex.order_utils.asset_data_utils import ( - encode_erc20_asset_data, 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, ) @@ -33,3 +36,37 @@ def test_decode_erc20_asset_data_invalid_proxy_id(): 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) + ) -- cgit v1.2.3