From 76b6b99f326890c375503f785077624f42efe637 Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Mon, 18 Mar 2019 16:22:26 +0530 Subject: [PATCH 01/15] Add basic implementation of EIP 712 --- eth_account/_utils/signing.py | 124 +++++++++++++++++++++++++++++++++- setup.py | 1 + 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/eth_account/_utils/signing.py b/eth_account/_utils/signing.py index 2a80867a..d54f208b 100644 --- a/eth_account/_utils/signing.py +++ b/eth_account/_utils/signing.py @@ -1,10 +1,17 @@ +import json + from cytoolz import ( curry, pipe, ) from eth_utils import ( + keccak, to_bytes, to_int, + to_text, +) +from eth_abi import ( + encode_abi, ) from eth_account._utils.transactions import ( @@ -21,6 +28,7 @@ # signature versions PERSONAL_SIGN_VERSION = b'E' # Hex value 0x45 INTENDED_VALIDATOR_SIGN_VERSION = b'\x00' # Hex value 0x00 +STRUCTURED_DATA_SIGN_VERSION = b'\x01' # Hex value 0x01 def sign_transaction_dict(eth_key, transaction_dict): @@ -44,6 +52,111 @@ def sign_transaction_dict(eth_key, transaction_dict): return (v, r, s, encoded_transaction) +# +# EIP712 Functionalities +# +def dependencies(primaryType, types, found=None): + """ + Recursively get all the dependencies of the primaryType + """ + # This is done to avoid the by-reference call of python + found = found or [] + + if primaryType in found: + return found + if primaryType not in types: + return found + + found.append(primaryType) + for field in types[primaryType]: + for dep in dependencies(field["type"], types, found): + if dep not in found: + found.push(dep) + + return found + + +def dict_to_type_name_converter(field): + """ + Given a dictionary ``field`` of type {'name': NAME, 'type': TYPE}, + this function converts it to ``TYPE NAME`` + """ + return field["type"] + " " + field["name"] + + +def encodeType(primaryType, types): + # Getting the dependencies and sorting them alphabetically as per EIP712 + deps = dependencies(primaryType, types) + deps_without_primary_type = list(filter(lambda x: x != primaryType, deps)) + sorted_deps = [primaryType] + sorted(deps_without_primary_type) + + result = ''.join( + [ + dep + "(" + ','.join(map(dict_to_type_name_converter, types[dep])) + ")" + for dep in sorted_deps + ] + ) + return result + + +def typeHash(primaryType, types): + return keccak(text=encodeType(primaryType, types)) + + +def encodeData(primaryType, types, data): + encTypes = [] + encValues = [] + + # Add typehash + encTypes.append("bytes32") + encValues.append(typeHash(primaryType, types)) + + # Add field contents + for field in types[primaryType]: + value = data[field["name"]] + if field["type"] == "string": + # Special case where the values need to be keccak hashed before they are encoded + encTypes.append("bytes32") + hashed_value = keccak(text=value) + encValues.append(hashed_value) + elif field["type"] == "bytes": + # Special case where the values need to be keccak hashed before they are encoded + encTypes.append("bytes32") + hashed_value = keccak(primitive=value) + encValues.append(hashed_value) + elif field["type"] in types: + # This means that this type is a user defined type + encTypes.append("bytes32") + hashed_value = keccak(primitive=encodeData(field["type"], types, value)) + encValues.append(hashed_value) + elif field["type"][-1] == "]": + # TODO: Replace the above conditionality with Regex for identifying arrays declaration + raise NotImplementedError("TODO: Arrays currently unimplemented in encodeData") + else: + encTypes.append(field["type"]) + encValues.append(value) + + return encode_abi(encTypes, encValues) + + +def hashStruct(structured_json_string_data, for_domain=False): + """ + The structured_json_string_data is expected to have the ``types`` attribute and + the ``primaryType``, ``message``, ``domain`` attribute. + The ``for_domain`` variable is used to calculate the ``hashStruct`` as part of the + ``domainSeparator`` calculation. + """ + structured_data = json.loads(structured_json_string_data) + types = structured_data["types"] + if for_domain: + primaryType = "EIP712Domain" + data = structured_data["domain"] + else: + primaryType = structured_data["primaryType"] + data = structured_data["message"] + return keccak(encodeData(primaryType, types, data)) + + # watch here for updates to signature format: https://github.com/ethereum/EIPs/issues/191 @curry def signature_wrapper(message, signature_version, version_specific_data): @@ -64,12 +177,19 @@ def signature_wrapper(message, signature_version, version_specific_data): raise TypeError("Invalid Wallet Address: {}".format(version_specific_data)) wrapped_message = b'\x19' + signature_version + wallet_address + message return wrapped_message + elif signature_version == STRUCTURED_DATA_SIGN_VERSION: + message_string = to_text(primitive=message) + # Here the version_specific_data is the EIP712Domain JSON string (includes type also) + domainSeparator = hashStruct(message_string, for_domain=True) + wrapped_message = b'\x19' + signature_version + domainSeparator + hashStruct(message_string) + return wrapped_message else: raise NotImplementedError( - "Currently supported signature versions are: {0}, {1}. ". + "Currently supported signature versions are: {0}, {1}, {2}. ". format( '0x' + INTENDED_VALIDATOR_SIGN_VERSION.hex(), - '0x' + PERSONAL_SIGN_VERSION.hex() + '0x' + PERSONAL_SIGN_VERSION.hex(), + '0x' + STRUCTURED_DATA_SIGN_VERSION.hex(), ) + "But received signature version {}".format('0x' + signature_version.hex()) ) diff --git a/setup.py b/setup.py index 2b4872e9..4c035a49 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,7 @@ include_package_data=True, install_requires=[ "attrdict>=2.0.0,<3", + "eth-abi", "eth-keyfile>=0.5.0,<0.6.0", "eth-keys>=0.2.0b3,<0.3.0", "eth-utils>=1.0.2,<2", From 572a22ce09b75247d427f83ca47ee8ff7cbbe097 Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Mon, 18 Mar 2019 16:22:49 +0530 Subject: [PATCH 02/15] Add EIP 712 tests --- tests/core/test_EIP712.py | 185 ++++++++++++++++++++++++++++++++++++ tests/core/test_accounts.py | 6 +- 2 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 tests/core/test_EIP712.py diff --git a/tests/core/test_EIP712.py b/tests/core/test_EIP712.py new file mode 100644 index 00000000..737ed420 --- /dev/null +++ b/tests/core/test_EIP712.py @@ -0,0 +1,185 @@ +import json + +from hexbytes import ( + HexBytes, +) +import pytest + +from eth_utils import ( + keccak, +) + +from eth_account import ( + Account, +) +from eth_account.messages import ( + defunct_hash_message, +) +from eth_account._utils.signing import ( + dependencies, + encodeData, + encodeType, + hashStruct, + typeHash, +) + + +@pytest.fixture +def structured_data_json_string(): + return '''{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "Person": [ + {"name": "name", "type": "string"}, + {"name": "wallet", "type": "address"} + ], + "Mail": [ + {"name": "from", "type": "Person"}, + {"name": "to", "type": "Person"}, + {"name": "contents", "type": "string"} + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents": "Hello, Bob!" + } + }''' + + +@pytest.fixture +def types(structured_data_json_string): + return json.loads(structured_data_json_string)["types"] + + +@pytest.fixture +def domain_type(structured_data_json_string): + return json.loads(structured_data_json_string)["types"]["EIP712Domain"] + + +@pytest.fixture +def message(structured_data_json_string): + return json.loads(structured_data_json_string)["message"] + + +@pytest.fixture +def domain(structured_data_json_string): + return json.loads(structured_data_json_string)["domain"] + + +@pytest.fixture(params=("text", "primitive", "hexstr")) +def signature_kwargs(request, structured_data_json_string): + if request == "text": + return {"text": structured_data_json_string} + elif request == "primitive": + return {"primitive": structured_data_json_string.encode()} + else: + return {"hexstr": structured_data_json_string.encode().hex()} + + +@pytest.mark.parametrize( + 'primary_type, expected', + ( + ('Mail', ['Mail', 'Person']), + ('Person', ['Person']), + ) +) +def test_dependencies(primary_type, types, expected): + assert dependencies(primary_type, types) == expected + + +@pytest.mark.parametrize( + 'primary_type, expected', + ( + ('Mail', 'Mail(Person from,Person to,string contents)Person(string name,address wallet)'), + ('Person', 'Person(string name,address wallet)'), + ) +) +def test_encodeType(primary_type, types, expected): + assert encodeType(primary_type, types) == expected + + +@pytest.mark.parametrize( + 'primary_type, expected_hex_value', + ( + ('Mail', 'a0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2'), + ('Person', 'b9d8c78acf9b987311de6c7b45bb6a9c8e1bf361fa7fd3467a2163f994c79500'), + ) +) +def test_typeHash(primary_type, types, expected_hex_value): + assert typeHash(primary_type, types).hex() == expected_hex_value + + +def test_encodeData(types, message): + primary_type = "Mail" + expected_hex_value = ( + "a0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2fc71e5fa27ff56c350aa531b" + "c129ebdf613b772b6604664f5d8dbe21b85eb0c8cd54f074a4af31b4411ff6a60c9719dbd559c221c8ac3492d9" + "d872b041d703d1b5aadf3154a261abdd9086fc627b61efca26ae5702701d05cd2305f7c52a2fc8" + ) + assert encodeData(primary_type, types, message).hex() == expected_hex_value + + +def test_hashStruct_main_message(structured_data_json_string): + expected_hex_value = "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e" + assert hashStruct(structured_data_json_string).hex() == expected_hex_value + + +def test_hashStruct_domain(structured_data_json_string): + expected_hex_value = "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f" + assert hashStruct(structured_data_json_string, for_domain=True).hex() == expected_hex_value + + +def test_hashed_structured_data(signature_kwargs): + hashed_structured_msg = defunct_hash_message( + **signature_kwargs, + signature_version=b'\x01', + ) + expected_hash_value_hex = "0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2" + assert hashed_structured_msg.hex() == expected_hash_value_hex + + +def test_signature_verification(signature_kwargs): + account = Account.create() + hashed_structured_msg = defunct_hash_message( + **signature_kwargs, + signature_version=b'\x01', + ) + signed = Account.signHash(hashed_structured_msg, account.privateKey) + new_addr = Account.recoverHash(hashed_structured_msg, signature=signed.signature) + assert new_addr == account.address + + +def test_signature_variables(signature_kwargs): + # Check that the signature of typed message is the same as that + # mentioned in the EIP. The link is as follows + # https://github.com/ethereum/EIPs/blob/master/assets/eip-712/Example.js + hashed_structured_msg = defunct_hash_message( + **signature_kwargs, + signature_version=b'\x01', + ) + privateKey = keccak(text="cow") + acc = Account.privateKeyToAccount(privateKey) + assert HexBytes(acc.address) == HexBytes("0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826") + sig = Account.signHash(hashed_structured_msg, privateKey) + assert sig.v == 28 + assert hex(sig.r) == "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d" + assert hex(sig.s) == "0x7299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562" diff --git a/tests/core/test_accounts.py b/tests/core/test_accounts.py index 01f3b296..08a5d91f 100644 --- a/tests/core/test_accounts.py +++ b/tests/core/test_accounts.py @@ -98,12 +98,12 @@ def acct(request): return Account -@pytest.fixture(params=("text", "primative", "hexstr")) +@pytest.fixture(params=("text", "primitive", "hexstr")) def signature_kwargs(request): if request == "text": return {"text": "hello world"} - elif request == "primative": - return {"primative": b"hello world"} + elif request == "primitive": + return {"primitive": b"hello world"} else: return {"hexstr": "68656c6c6f20776f726c64"} From f3fc3663ebc1430b7334f4ad12653c8df074479a Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Mon, 18 Mar 2019 16:40:03 +0530 Subject: [PATCH 03/15] Make isort happy --- eth_account/_utils/signing.py | 6 +++--- tests/core/test_EIP712.py | 13 ++++++------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/eth_account/_utils/signing.py b/eth_account/_utils/signing.py index d54f208b..b7d66320 100644 --- a/eth_account/_utils/signing.py +++ b/eth_account/_utils/signing.py @@ -4,15 +4,15 @@ curry, pipe, ) +from eth_abi import ( + encode_abi, +) from eth_utils import ( keccak, to_bytes, to_int, to_text, ) -from eth_abi import ( - encode_abi, -) from eth_account._utils.transactions import ( ChainAwareUnsignedTransaction, diff --git a/tests/core/test_EIP712.py b/tests/core/test_EIP712.py index 737ed420..ed861895 100644 --- a/tests/core/test_EIP712.py +++ b/tests/core/test_EIP712.py @@ -1,20 +1,16 @@ import json - -from hexbytes import ( - HexBytes, -) import pytest from eth_utils import ( keccak, ) +from hexbytes import ( + HexBytes, +) from eth_account import ( Account, ) -from eth_account.messages import ( - defunct_hash_message, -) from eth_account._utils.signing import ( dependencies, encodeData, @@ -22,6 +18,9 @@ hashStruct, typeHash, ) +from eth_account.messages import ( + defunct_hash_message, +) @pytest.fixture From 86cf5ab910f8afee59257d7aff841ec1c566b2fc Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Tue, 19 Mar 2019 12:39:33 +0530 Subject: [PATCH 04/15] Add error handling and validations --- eth_account/_utils/signing.py | 74 ++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/eth_account/_utils/signing.py b/eth_account/_utils/signing.py index b7d66320..2ccfd37c 100644 --- a/eth_account/_utils/signing.py +++ b/eth_account/_utils/signing.py @@ -1,4 +1,5 @@ import json +import re from cytoolz import ( curry, @@ -6,6 +7,7 @@ ) from eth_abi import ( encode_abi, + is_encodable, ) from eth_utils import ( keccak, @@ -30,6 +32,10 @@ INTENDED_VALIDATOR_SIGN_VERSION = b'\x00' # Hex value 0x00 STRUCTURED_DATA_SIGN_VERSION = b'\x01' # Hex value 0x01 +# Regexes +IDENTIFIER_REGEX = r"^[a-zA-Z_$][a-zA-Z_$0-9]*$" +TYPE_REGEX = r"^[a-zA-Z_$][a-zA-Z_$0-9]*(\[([1-9]\d*)*\])*$" + def sign_transaction_dict(eth_key, transaction_dict): # generate RLP-serializable transaction, with defaults filled @@ -115,11 +121,31 @@ def encodeData(primaryType, types, data): for field in types[primaryType]: value = data[field["name"]] if field["type"] == "string": + if not isinstance(value, str): + raise TypeError( + "Value of `{0}` ({2}) of field `{1}` is of the type `{3}`, but expected " + "string value".format( + field["name"], + primaryType, + value, + type(value), + ) + ) # Special case where the values need to be keccak hashed before they are encoded encTypes.append("bytes32") hashed_value = keccak(text=value) encValues.append(hashed_value) elif field["type"] == "bytes": + if not isinstance(value, bytes): + raise TypeError( + "Value of `{0}` ({2}) of field `{1}` is of the type `{3}`, but expected " + "bytes value".format( + field["name"], + primaryType, + value, + type(value), + ) + ) # Special case where the values need to be keccak hashed before they are encoded encTypes.append("bytes32") hashed_value = keccak(primitive=value) @@ -133,12 +159,54 @@ def encodeData(primaryType, types, data): # TODO: Replace the above conditionality with Regex for identifying arrays declaration raise NotImplementedError("TODO: Arrays currently unimplemented in encodeData") else: - encTypes.append(field["type"]) - encValues.append(value) + # First checking to see if the individual values can be encoded + try: + is_encodable(field["type"], value) + except: + raise AttributeError( + "Received Invalid type `{0}` of field `{1}`".format( + field["type"], + primaryType, + ) + ) + + # Next see if the data fits the specified encoding type + if is_encodable(field["type"], value): + # field["type"] is a valid type and this value corresponds to that type. + encTypes.append(field["type"]) + encValues.append(value) + else: + raise TypeError( + "Value of `{0}` ({2}) of field `{1}` is of the type `{3}`, but expected " + "{4} value".format( + field["name"], + primaryType, + value, + type(value), + field["type"], + ) + ) return encode_abi(encTypes, encValues) +def validate_structured_data(structured_data): + # Check if all the `name` and the `type` attributes in each field of all the + # `types` are valid (Regex Check) + for field_type in structured_data["types"]: + for field in structured_data["types"][field_type]: + # Check that field["name"] matches with IDENTIFIER_REGEX + if not re.match(IDENTIFIER_REGEX, field["name"]): + raise AttributeError( + "Invalid Identifier `{}` in `{}`".format(field["name"], field_type) + ) + # Check that field["type"] matches with TYPE_REGEX + if not re.match(TYPE_REGEX, field["type"]): + raise AttributeError( + "Invalid Type `{}` in `{}`".format(field["type"], field_type) + ) + + def hashStruct(structured_json_string_data, for_domain=False): """ The structured_json_string_data is expected to have the ``types`` attribute and @@ -147,6 +215,8 @@ def hashStruct(structured_json_string_data, for_domain=False): ``domainSeparator`` calculation. """ structured_data = json.loads(structured_json_string_data) + validate_structured_data(structured_data) + types = structured_data["types"] if for_domain: primaryType = "EIP712Domain" From 971f41fa61347fa371a017847c85b76b2d5694a0 Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Tue, 19 Mar 2019 12:39:47 +0530 Subject: [PATCH 05/15] Add tests for the error handling cases --- tests/core/test_EIP712.py | 287 +++++++++++++++++++++++++++++++++++--- 1 file changed, 270 insertions(+), 17 deletions(-) diff --git a/tests/core/test_EIP712.py b/tests/core/test_EIP712.py index ed861895..8ce40158 100644 --- a/tests/core/test_EIP712.py +++ b/tests/core/test_EIP712.py @@ -1,5 +1,6 @@ import json import pytest +import re from eth_utils import ( keccak, @@ -12,6 +13,7 @@ Account, ) from eth_account._utils.signing import ( + TYPE_REGEX, dependencies, encodeData, encodeType, @@ -24,7 +26,7 @@ @pytest.fixture -def structured_data_json_string(): +def structured_valid_data_json_string(): return '''{ "types": { "EIP712Domain": [ @@ -65,33 +67,33 @@ def structured_data_json_string(): @pytest.fixture -def types(structured_data_json_string): - return json.loads(structured_data_json_string)["types"] +def types(structured_valid_data_json_string): + return json.loads(structured_valid_data_json_string)["types"] @pytest.fixture -def domain_type(structured_data_json_string): - return json.loads(structured_data_json_string)["types"]["EIP712Domain"] +def domain_type(structured_valid_data_json_string): + return json.loads(structured_valid_data_json_string)["types"]["EIP712Domain"] @pytest.fixture -def message(structured_data_json_string): - return json.loads(structured_data_json_string)["message"] +def message(structured_valid_data_json_string): + return json.loads(structured_valid_data_json_string)["message"] @pytest.fixture -def domain(structured_data_json_string): - return json.loads(structured_data_json_string)["domain"] +def domain(structured_valid_data_json_string): + return json.loads(structured_valid_data_json_string)["domain"] @pytest.fixture(params=("text", "primitive", "hexstr")) -def signature_kwargs(request, structured_data_json_string): +def signature_kwargs(request, structured_valid_data_json_string): if request == "text": - return {"text": structured_data_json_string} + return {"text": structured_valid_data_json_string} elif request == "primitive": - return {"primitive": structured_data_json_string.encode()} + return {"primitive": structured_valid_data_json_string.encode()} else: - return {"hexstr": structured_data_json_string.encode().hex()} + return {"hexstr": structured_valid_data_json_string.encode().hex()} @pytest.mark.parametrize( @@ -137,14 +139,16 @@ def test_encodeData(types, message): assert encodeData(primary_type, types, message).hex() == expected_hex_value -def test_hashStruct_main_message(structured_data_json_string): +def test_hashStruct_main_message(structured_valid_data_json_string): expected_hex_value = "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e" - assert hashStruct(structured_data_json_string).hex() == expected_hex_value + assert hashStruct(structured_valid_data_json_string).hex() == expected_hex_value -def test_hashStruct_domain(structured_data_json_string): +def test_hashStruct_domain(structured_valid_data_json_string): expected_hex_value = "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f" - assert hashStruct(structured_data_json_string, for_domain=True).hex() == expected_hex_value + assert ( + hashStruct(structured_valid_data_json_string, for_domain=True).hex() == expected_hex_value + ) def test_hashed_structured_data(signature_kwargs): @@ -182,3 +186,252 @@ def test_signature_variables(signature_kwargs): assert sig.v == 28 assert hex(sig.r) == "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d" assert hex(sig.s) == "0x7299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562" + + +@pytest.mark.parametrize( + 'type, valid', + ( + ("unint bytes32", False), + ("hello\\[]", False), + ("byte[]uint", False), + ("byte[7[]uint][]", False), + ("Person[0]", False), + + ("bytes32", True), + ("Foo[]", True), + ("bytes1", True), + ("bytes32[][][]", True), + ("byte[9]", True), + ("Person[1]", True), + ) +) +def test_type_regex(type, valid): + if valid: + assert re.match(TYPE_REGEX, type) is not None + else: + assert re.match(TYPE_REGEX, type) is None + + +def test_structured_data_invalid_identifier_filtered_by_regex(): + invalid_structured_data_string = '''{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "Person": [ + {"name": "name", "type": "string"}, + {"name": "hello wallet", "type": "address"} + ], + "Mail": [ + {"name": "from", "type": "Person"}, + {"name": "to", "type": "Person"}, + {"name": "contents", "type": "string"} + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents": "Hello, Bob!" + } + }''' + with pytest.raises(AttributeError) as e: + hashStruct(invalid_structured_data_string) + assert e == "Invalid Identifier `hello wallet` in `Person`" + + +def test_structured_data_invalid_type_filtered_by_regex(): + invalid_structured_data_string = '''{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "Person": [ + {"name": "name", "type": "string"}, + {"name": "wallet", "type": "address"} + ], + "Mail": [ + {"name": "from", "type": "Hello Person"}, + {"name": "to", "type": "Person"}, + {"name": "contents", "type": "string"} + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents": "Hello, Bob!" + } + }''' + with pytest.raises(AttributeError) as e: + hashStruct(invalid_structured_data_string) + assert e == "Invalid Type `Hello Person` in `Mail`" + + +def test_invalid_structured_data_value_type_mismatch_in_primary_type(): + # Given type is valid (string), but the value (int) is not of the mentioned type + invalid_structured_data_string = '''{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "Person": [ + {"name": "name", "type": "string"}, + {"name": "wallet", "type": "address"} + ], + "Mail": [ + {"name": "from", "type": "Person"}, + {"name": "to", "type": "Person"}, + {"name": "contents", "type": "string"} + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents": 12345 + } + }''' + with pytest.raises(TypeError) as e: + hashStruct(invalid_structured_data_string) + assert ( + e == "Value of `contents` (12345) of field `Mail` is of the " + "type ``, but expected string value" + ) + + +def test_invalid_structured_data_invalid_abi_type(): + # Given type/types are invalid + invalid_structured_data_string = '''{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "Person": [ + {"name": "name", "type": "string"}, + {"name": "balance", "type": "uint25689"} + ], + "Mail": [ + {"name": "from", "type": "Person"}, + {"name": "to", "type": "Person"}, + {"name": "contents", "type": "string"} + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "balance": 123 + }, + "to": { + "name": "Bob", + "balance": 1234 + }, + "contents": "Hello Bob!" + } + }''' + with pytest.raises(AttributeError) as e: + hashStruct(invalid_structured_data_string) + assert e == "Received Invalid type `uint25689` of field `Person`" + + +def test_structured_data_invalid_identifier_filtered_by_abi_encodable_function(): + # Given valid abi type, but the value is not of the specified type + # (found by the is_encodable function) + invalid_structured_data_string = '''{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "Person": [ + {"name": "name", "type": "string"}, + {"name": "balance", "type": "uint256"} + ], + "Mail": [ + {"name": "from", "type": "Person"}, + {"name": "to", "type": "Person"}, + {"name": "contents", "type": "string"} + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "balance": 1234 + }, + "to": { + "name": "Bob", + "balance": "how do you do?" + }, + "contents": "Hello Bob!" + } + }''' + with pytest.raises(TypeError) as e: + hashStruct(invalid_structured_data_string) + assert ( + e == "Value of `balance` (how do you do?) of field `Person` is of the " + "type ``, but expected uint256 value" + ) From 0756d929869d4c06759cad740b031fb5522dc3cd Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Thu, 21 Mar 2019 14:50:03 +0530 Subject: [PATCH 06/15] Replace some of the AttributeError with TypeError --- eth_account/_utils/signing.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/eth_account/_utils/signing.py b/eth_account/_utils/signing.py index 2ccfd37c..a788ca0c 100644 --- a/eth_account/_utils/signing.py +++ b/eth_account/_utils/signing.py @@ -91,6 +91,10 @@ def dict_to_type_name_converter(field): def encodeType(primaryType, types): + """ + The type of a struct is encoded as name ‖ "(" ‖ member₁ ‖ "," ‖ member₂ ‖ "," ‖ … ‖ memberₙ ")" + where each member is written as type ‖ " " ‖ name. + """ # Getting the dependencies and sorting them alphabetically as per EIP712 deps = dependencies(primaryType, types) deps_without_primary_type = list(filter(lambda x: x != primaryType, deps)) @@ -98,8 +102,8 @@ def encodeType(primaryType, types): result = ''.join( [ - dep + "(" + ','.join(map(dict_to_type_name_converter, types[dep])) + ")" - for dep in sorted_deps + struct_name + "(" + ','.join(map(dict_to_type_name_converter, types[struct_name])) + ")" + for struct_name in sorted_deps ] ) return result @@ -123,7 +127,7 @@ def encodeData(primaryType, types, data): if field["type"] == "string": if not isinstance(value, str): raise TypeError( - "Value of `{0}` ({2}) of field `{1}` is of the type `{3}`, but expected " + "Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected " "string value".format( field["name"], primaryType, @@ -138,7 +142,7 @@ def encodeData(primaryType, types, data): elif field["type"] == "bytes": if not isinstance(value, bytes): raise TypeError( - "Value of `{0}` ({2}) of field `{1}` is of the type `{3}`, but expected " + "Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected " "bytes value".format( field["name"], primaryType, @@ -163,8 +167,8 @@ def encodeData(primaryType, types, data): try: is_encodable(field["type"], value) except: - raise AttributeError( - "Received Invalid type `{0}` of field `{1}`".format( + raise TypeError( + "Received Invalid type `{0}` in the struct `{1}`".format( field["type"], primaryType, ) @@ -177,7 +181,7 @@ def encodeData(primaryType, types, data): encValues.append(value) else: raise TypeError( - "Value of `{0}` ({2}) of field `{1}` is of the type `{3}`, but expected " + "Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected " "{4} value".format( field["name"], primaryType, @@ -207,18 +211,18 @@ def validate_structured_data(structured_data): ) -def hashStruct(structured_json_string_data, for_domain=False): +def hashStruct(structured_json_string_data, is_domain_separator=False): """ The structured_json_string_data is expected to have the ``types`` attribute and the ``primaryType``, ``message``, ``domain`` attribute. - The ``for_domain`` variable is used to calculate the ``hashStruct`` as part of the - ``domainSeparator`` calculation. + The ``is_domain_separator`` variable is used to calculate the ``hashStruct`` as + part of the ``domainSeparator`` calculation. """ structured_data = json.loads(structured_json_string_data) validate_structured_data(structured_data) types = structured_data["types"] - if for_domain: + if is_domain_separator: primaryType = "EIP712Domain" data = structured_data["domain"] else: @@ -227,7 +231,9 @@ def hashStruct(structured_json_string_data, for_domain=False): return keccak(encodeData(primaryType, types, data)) -# watch here for updates to signature format: https://github.com/ethereum/EIPs/issues/191 +# watch here for updates to signature format: +# https://github.com/ethereum/EIPs/blob/master/EIPS/eip-191.md +# https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md @curry def signature_wrapper(message, signature_version, version_specific_data): if not isinstance(message, bytes): @@ -249,8 +255,7 @@ def signature_wrapper(message, signature_version, version_specific_data): return wrapped_message elif signature_version == STRUCTURED_DATA_SIGN_VERSION: message_string = to_text(primitive=message) - # Here the version_specific_data is the EIP712Domain JSON string (includes type also) - domainSeparator = hashStruct(message_string, for_domain=True) + domainSeparator = hashStruct(message_string, is_domain_separator=True) wrapped_message = b'\x19' + signature_version + domainSeparator + hashStruct(message_string) return wrapped_message else: From 175f8a34c9795fbfb1955661e0d7a7cff26b047c Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Thu, 21 Mar 2019 14:51:06 +0530 Subject: [PATCH 07/15] Pin eth-abi to >=1.0.0 and <2.0.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4c035a49..8f6b7528 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ include_package_data=True, install_requires=[ "attrdict>=2.0.0,<3", - "eth-abi", + "eth-abi>=1.0.0,<2.0.0", "eth-keyfile>=0.5.0,<0.6.0", "eth-keys>=0.2.0b3,<0.3.0", "eth-utils>=1.0.2,<2", From 195f447c438ceb3dd0310063530aa8b91a1d4760 Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Thu, 21 Mar 2019 14:52:24 +0530 Subject: [PATCH 08/15] Rename test_EIP712.py to test_structured_data_signing.py --- ...712.py => test_structured_data_signing.py} | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) rename tests/core/{test_EIP712.py => test_structured_data_signing.py} (94%) diff --git a/tests/core/test_EIP712.py b/tests/core/test_structured_data_signing.py similarity index 94% rename from tests/core/test_EIP712.py rename to tests/core/test_structured_data_signing.py index 8ce40158..a5c7d600 100644 --- a/tests/core/test_EIP712.py +++ b/tests/core/test_structured_data_signing.py @@ -147,7 +147,8 @@ def test_hashStruct_main_message(structured_valid_data_json_string): def test_hashStruct_domain(structured_valid_data_json_string): expected_hex_value = "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f" assert ( - hashStruct(structured_valid_data_json_string, for_domain=True).hex() == expected_hex_value + hashStruct(structured_valid_data_json_string, is_domain_separator=True).hex() == + expected_hex_value ) @@ -252,7 +253,7 @@ def test_structured_data_invalid_identifier_filtered_by_regex(): }''' with pytest.raises(AttributeError) as e: hashStruct(invalid_structured_data_string) - assert e == "Invalid Identifier `hello wallet` in `Person`" + assert str(e.value) == "Invalid Identifier `hello wallet` in `Person`" def test_structured_data_invalid_type_filtered_by_regex(): @@ -295,7 +296,7 @@ def test_structured_data_invalid_type_filtered_by_regex(): }''' with pytest.raises(AttributeError) as e: hashStruct(invalid_structured_data_string) - assert e == "Invalid Type `Hello Person` in `Mail`" + assert str(e.value) == "Invalid Type `Hello Person` in `Mail`" def test_invalid_structured_data_value_type_mismatch_in_primary_type(): @@ -339,10 +340,10 @@ def test_invalid_structured_data_value_type_mismatch_in_primary_type(): }''' with pytest.raises(TypeError) as e: hashStruct(invalid_structured_data_string) - assert ( - e == "Value of `contents` (12345) of field `Mail` is of the " - "type ``, but expected string value" - ) + assert ( + str(e.value) == "Value of `contents` (12345) in the struct `Mail` is of the " + "type ``, but expected string value" + ) def test_invalid_structured_data_invalid_abi_type(): @@ -384,14 +385,14 @@ def test_invalid_structured_data_invalid_abi_type(): "contents": "Hello Bob!" } }''' - with pytest.raises(AttributeError) as e: + with pytest.raises(TypeError) as e: hashStruct(invalid_structured_data_string) - assert e == "Received Invalid type `uint25689` of field `Person`" + assert str(e.value) == "Received Invalid type `uint25689` in the struct `Person`" def test_structured_data_invalid_identifier_filtered_by_abi_encodable_function(): # Given valid abi type, but the value is not of the specified type - # (found by the is_encodable function) + # (Error is found by the ``is_encodable`` ABI function) invalid_structured_data_string = '''{ "types": { "EIP712Domain": [ @@ -431,7 +432,7 @@ def test_structured_data_invalid_identifier_filtered_by_abi_encodable_function() }''' with pytest.raises(TypeError) as e: hashStruct(invalid_structured_data_string) - assert ( - e == "Value of `balance` (how do you do?) of field `Person` is of the " - "type ``, but expected uint256 value" - ) + assert ( + str(e.value) == "Value of `balance` (how do you do?) in the struct `Person` is of the " + "type ``, but expected uint256 value" + ) From 0f150c418b3c8eb9150e973dd0b17efa80056d22 Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Thu, 21 Mar 2019 22:05:31 +0530 Subject: [PATCH 09/15] Add JSON message string validations --- eth_account/_utils/signing.py | 97 ++++++++++++++++++++++++++++++++--- 1 file changed, 91 insertions(+), 6 deletions(-) diff --git a/eth_account/_utils/signing.py b/eth_account/_utils/signing.py index a788ca0c..805842ea 100644 --- a/eth_account/_utils/signing.py +++ b/eth_account/_utils/signing.py @@ -194,23 +194,108 @@ def encodeData(primaryType, types, data): return encode_abi(encTypes, encValues) -def validate_structured_data(structured_data): +def validate_has_attribute(attr_name, dict_data): + if attr_name not in dict_data: + raise AttributeError( + "Attribute `{0}` not found in the JSON string". + format(attr_name) + ) + + +def validate_types_attribute(structured_data): + # Check that the data has `types` attribute + validate_has_attribute("types", structured_data) + # Check if all the `name` and the `type` attributes in each field of all the - # `types` are valid (Regex Check) - for field_type in structured_data["types"]: - for field in structured_data["types"][field_type]: + # `types` attribute are valid (Regex Check) + for struct_name in structured_data["types"]: + # Check that `struct_name` is of the type string + if not isinstance(struct_name, str): + raise TypeError( + "Struct Name of `types` attribute should be a string, but got type `{0}`". + format(type(struct_name)) + ) + for field in structured_data["types"][struct_name]: + # Check that `field["name"]` is of the type string + if not isinstance(field["name"], str): + raise TypeError( + "Field Name `{0}` of struct `{1}` should be a string, but got type `{2}`". + format(field["name"], struct_name, type(field["name"])) + ) + # Check that `field["type"]` is of the type string + if not isinstance(field["type"], str): + raise TypeError( + "Field Type `{0}` of struct `{1}` should be a string, but got type `{2}`". + format(field["type"], struct_name, type(field["type"])) + ) # Check that field["name"] matches with IDENTIFIER_REGEX if not re.match(IDENTIFIER_REGEX, field["name"]): raise AttributeError( - "Invalid Identifier `{}` in `{}`".format(field["name"], field_type) + "Invalid Identifier `{0}` in `{1}`".format(field["name"], struct_name) ) # Check that field["type"] matches with TYPE_REGEX if not re.match(TYPE_REGEX, field["type"]): raise AttributeError( - "Invalid Type `{}` in `{}`".format(field["type"], field_type) + "Invalid Type `{0}` in `{1}`".format(field["type"], struct_name) ) +def validate_field_declared_only_once_in_struct(field_name, struct_data, struct_name): + if len([field for field in struct_data if field["name"] == field_name]) != 1: + raise AttributeError( + "Attribute `{0}` not declared or declared more than once in {1}". + format(field_name, struct_name) + ) + + +def validate_EIP712Domain_schema(structured_data): + # Check that the `types` attribute contains `EIP712Domain` schema declaration + if "EIP712Domain" not in structured_data["types"]: + raise AttributeError("`EIP712Domain struct` not found in types attribute") + # Check that the names and types in `EIP712Domain` are what are mentioned in the EIP-712 + # and they are declared only once + EIP712Domain_data = structured_data["types"]["EIP712Domain"] + validate_field_declared_only_once_in_struct("name", EIP712Domain_data, "EIP712Domain") + validate_field_declared_only_once_in_struct("version", EIP712Domain_data, "EIP712Domain") + validate_field_declared_only_once_in_struct("chainId", EIP712Domain_data, "EIP712Domain") + validate_field_declared_only_once_in_struct( + "verifyingContract", + EIP712Domain_data, + "EIP712Domain", + ) + + +def validate_primaryType_attribute(structured_data): + # Check that `primaryType` attribute is present + if "primaryType" not in structured_data: + raise AttributeError("The Structured Data needs to have a `primaryType` attribute") + # Check that `primaryType` value is a string + if not isinstance(structured_data["primaryType"], str): + raise TypeError( + "Value of attribute `primaryType` should be `string`, but got type `{0}`". + format(type(structured_data["primaryType"])) + ) + # Check that the value of `primaryType` is present in the `types` attribute + if not structured_data["primaryType"] in structured_data["types"]: + raise AttributeError( + "The Primary Type `{0}` is not present in the `types` attribute". + format(structured_data["primaryType"]) + ) + + +def validate_structured_data(structured_data): + # validate the `types` attribute + validate_types_attribute(structured_data) + # validate the `EIP712Domain` attribute of `types` attribute + validate_EIP712Domain_schema(structured_data) + # validate the `primaryType` attribute + validate_primaryType_attribute(structured_data) + # Check that there is a `domain` attribute in the structured data + validate_has_attribute("domain", structured_data) + # Check that there is a `message` attribute in the structured data + validate_has_attribute("message", structured_data) + + def hashStruct(structured_json_string_data, is_domain_separator=False): """ The structured_json_string_data is expected to have the ``types`` attribute and From a837cad87383dfdc48382e054e1c159dc65b96d9 Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Fri, 22 Mar 2019 19:38:22 +0530 Subject: [PATCH 10/15] Move the structured_data functionalities into separate _utils module --- eth_account/_utils/signing.py | 273 +----------------- .../_utils/structured_data/__init__.py | 3 + eth_account/_utils/structured_data/hashing.py | 212 ++++++++++++++ .../_utils/structured_data/validation.py | 111 +++++++ tests/core/test_structured_data_signing.py | 31 +- 5 files changed, 354 insertions(+), 276 deletions(-) create mode 100644 eth_account/_utils/structured_data/__init__.py create mode 100644 eth_account/_utils/structured_data/hashing.py create mode 100644 eth_account/_utils/structured_data/validation.py diff --git a/eth_account/_utils/signing.py b/eth_account/_utils/signing.py index 805842ea..5b439a5c 100644 --- a/eth_account/_utils/signing.py +++ b/eth_account/_utils/signing.py @@ -1,21 +1,16 @@ -import json -import re - from cytoolz import ( curry, pipe, ) -from eth_abi import ( - encode_abi, - is_encodable, -) from eth_utils import ( - keccak, to_bytes, to_int, to_text, ) +from eth_account._utils.structured_data import ( + hashStruct, +) from eth_account._utils.transactions import ( ChainAwareUnsignedTransaction, UnsignedTransaction, @@ -32,10 +27,6 @@ INTENDED_VALIDATOR_SIGN_VERSION = b'\x00' # Hex value 0x00 STRUCTURED_DATA_SIGN_VERSION = b'\x01' # Hex value 0x01 -# Regexes -IDENTIFIER_REGEX = r"^[a-zA-Z_$][a-zA-Z_$0-9]*$" -TYPE_REGEX = r"^[a-zA-Z_$][a-zA-Z_$0-9]*(\[([1-9]\d*)*\])*$" - def sign_transaction_dict(eth_key, transaction_dict): # generate RLP-serializable transaction, with defaults filled @@ -58,264 +49,6 @@ def sign_transaction_dict(eth_key, transaction_dict): return (v, r, s, encoded_transaction) -# -# EIP712 Functionalities -# -def dependencies(primaryType, types, found=None): - """ - Recursively get all the dependencies of the primaryType - """ - # This is done to avoid the by-reference call of python - found = found or [] - - if primaryType in found: - return found - if primaryType not in types: - return found - - found.append(primaryType) - for field in types[primaryType]: - for dep in dependencies(field["type"], types, found): - if dep not in found: - found.push(dep) - - return found - - -def dict_to_type_name_converter(field): - """ - Given a dictionary ``field`` of type {'name': NAME, 'type': TYPE}, - this function converts it to ``TYPE NAME`` - """ - return field["type"] + " " + field["name"] - - -def encodeType(primaryType, types): - """ - The type of a struct is encoded as name ‖ "(" ‖ member₁ ‖ "," ‖ member₂ ‖ "," ‖ … ‖ memberₙ ")" - where each member is written as type ‖ " " ‖ name. - """ - # Getting the dependencies and sorting them alphabetically as per EIP712 - deps = dependencies(primaryType, types) - deps_without_primary_type = list(filter(lambda x: x != primaryType, deps)) - sorted_deps = [primaryType] + sorted(deps_without_primary_type) - - result = ''.join( - [ - struct_name + "(" + ','.join(map(dict_to_type_name_converter, types[struct_name])) + ")" - for struct_name in sorted_deps - ] - ) - return result - - -def typeHash(primaryType, types): - return keccak(text=encodeType(primaryType, types)) - - -def encodeData(primaryType, types, data): - encTypes = [] - encValues = [] - - # Add typehash - encTypes.append("bytes32") - encValues.append(typeHash(primaryType, types)) - - # Add field contents - for field in types[primaryType]: - value = data[field["name"]] - if field["type"] == "string": - if not isinstance(value, str): - raise TypeError( - "Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected " - "string value".format( - field["name"], - primaryType, - value, - type(value), - ) - ) - # Special case where the values need to be keccak hashed before they are encoded - encTypes.append("bytes32") - hashed_value = keccak(text=value) - encValues.append(hashed_value) - elif field["type"] == "bytes": - if not isinstance(value, bytes): - raise TypeError( - "Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected " - "bytes value".format( - field["name"], - primaryType, - value, - type(value), - ) - ) - # Special case where the values need to be keccak hashed before they are encoded - encTypes.append("bytes32") - hashed_value = keccak(primitive=value) - encValues.append(hashed_value) - elif field["type"] in types: - # This means that this type is a user defined type - encTypes.append("bytes32") - hashed_value = keccak(primitive=encodeData(field["type"], types, value)) - encValues.append(hashed_value) - elif field["type"][-1] == "]": - # TODO: Replace the above conditionality with Regex for identifying arrays declaration - raise NotImplementedError("TODO: Arrays currently unimplemented in encodeData") - else: - # First checking to see if the individual values can be encoded - try: - is_encodable(field["type"], value) - except: - raise TypeError( - "Received Invalid type `{0}` in the struct `{1}`".format( - field["type"], - primaryType, - ) - ) - - # Next see if the data fits the specified encoding type - if is_encodable(field["type"], value): - # field["type"] is a valid type and this value corresponds to that type. - encTypes.append(field["type"]) - encValues.append(value) - else: - raise TypeError( - "Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected " - "{4} value".format( - field["name"], - primaryType, - value, - type(value), - field["type"], - ) - ) - - return encode_abi(encTypes, encValues) - - -def validate_has_attribute(attr_name, dict_data): - if attr_name not in dict_data: - raise AttributeError( - "Attribute `{0}` not found in the JSON string". - format(attr_name) - ) - - -def validate_types_attribute(structured_data): - # Check that the data has `types` attribute - validate_has_attribute("types", structured_data) - - # Check if all the `name` and the `type` attributes in each field of all the - # `types` attribute are valid (Regex Check) - for struct_name in structured_data["types"]: - # Check that `struct_name` is of the type string - if not isinstance(struct_name, str): - raise TypeError( - "Struct Name of `types` attribute should be a string, but got type `{0}`". - format(type(struct_name)) - ) - for field in structured_data["types"][struct_name]: - # Check that `field["name"]` is of the type string - if not isinstance(field["name"], str): - raise TypeError( - "Field Name `{0}` of struct `{1}` should be a string, but got type `{2}`". - format(field["name"], struct_name, type(field["name"])) - ) - # Check that `field["type"]` is of the type string - if not isinstance(field["type"], str): - raise TypeError( - "Field Type `{0}` of struct `{1}` should be a string, but got type `{2}`". - format(field["type"], struct_name, type(field["type"])) - ) - # Check that field["name"] matches with IDENTIFIER_REGEX - if not re.match(IDENTIFIER_REGEX, field["name"]): - raise AttributeError( - "Invalid Identifier `{0}` in `{1}`".format(field["name"], struct_name) - ) - # Check that field["type"] matches with TYPE_REGEX - if not re.match(TYPE_REGEX, field["type"]): - raise AttributeError( - "Invalid Type `{0}` in `{1}`".format(field["type"], struct_name) - ) - - -def validate_field_declared_only_once_in_struct(field_name, struct_data, struct_name): - if len([field for field in struct_data if field["name"] == field_name]) != 1: - raise AttributeError( - "Attribute `{0}` not declared or declared more than once in {1}". - format(field_name, struct_name) - ) - - -def validate_EIP712Domain_schema(structured_data): - # Check that the `types` attribute contains `EIP712Domain` schema declaration - if "EIP712Domain" not in structured_data["types"]: - raise AttributeError("`EIP712Domain struct` not found in types attribute") - # Check that the names and types in `EIP712Domain` are what are mentioned in the EIP-712 - # and they are declared only once - EIP712Domain_data = structured_data["types"]["EIP712Domain"] - validate_field_declared_only_once_in_struct("name", EIP712Domain_data, "EIP712Domain") - validate_field_declared_only_once_in_struct("version", EIP712Domain_data, "EIP712Domain") - validate_field_declared_only_once_in_struct("chainId", EIP712Domain_data, "EIP712Domain") - validate_field_declared_only_once_in_struct( - "verifyingContract", - EIP712Domain_data, - "EIP712Domain", - ) - - -def validate_primaryType_attribute(structured_data): - # Check that `primaryType` attribute is present - if "primaryType" not in structured_data: - raise AttributeError("The Structured Data needs to have a `primaryType` attribute") - # Check that `primaryType` value is a string - if not isinstance(structured_data["primaryType"], str): - raise TypeError( - "Value of attribute `primaryType` should be `string`, but got type `{0}`". - format(type(structured_data["primaryType"])) - ) - # Check that the value of `primaryType` is present in the `types` attribute - if not structured_data["primaryType"] in structured_data["types"]: - raise AttributeError( - "The Primary Type `{0}` is not present in the `types` attribute". - format(structured_data["primaryType"]) - ) - - -def validate_structured_data(structured_data): - # validate the `types` attribute - validate_types_attribute(structured_data) - # validate the `EIP712Domain` attribute of `types` attribute - validate_EIP712Domain_schema(structured_data) - # validate the `primaryType` attribute - validate_primaryType_attribute(structured_data) - # Check that there is a `domain` attribute in the structured data - validate_has_attribute("domain", structured_data) - # Check that there is a `message` attribute in the structured data - validate_has_attribute("message", structured_data) - - -def hashStruct(structured_json_string_data, is_domain_separator=False): - """ - The structured_json_string_data is expected to have the ``types`` attribute and - the ``primaryType``, ``message``, ``domain`` attribute. - The ``is_domain_separator`` variable is used to calculate the ``hashStruct`` as - part of the ``domainSeparator`` calculation. - """ - structured_data = json.loads(structured_json_string_data) - validate_structured_data(structured_data) - - types = structured_data["types"] - if is_domain_separator: - primaryType = "EIP712Domain" - data = structured_data["domain"] - else: - primaryType = structured_data["primaryType"] - data = structured_data["message"] - return keccak(encodeData(primaryType, types, data)) - - # watch here for updates to signature format: # https://github.com/ethereum/EIPs/blob/master/EIPS/eip-191.md # https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md diff --git a/eth_account/_utils/structured_data/__init__.py b/eth_account/_utils/structured_data/__init__.py new file mode 100644 index 00000000..dfcb76f7 --- /dev/null +++ b/eth_account/_utils/structured_data/__init__.py @@ -0,0 +1,3 @@ +from .hashing import ( # noqa: F401 + hashStruct, +) diff --git a/eth_account/_utils/structured_data/hashing.py b/eth_account/_utils/structured_data/hashing.py new file mode 100644 index 00000000..22e64310 --- /dev/null +++ b/eth_account/_utils/structured_data/hashing.py @@ -0,0 +1,212 @@ +import json + +from eth_abi import ( + encode_abi, + is_encodable, +) +from eth_utils import ( + keccak, + to_tuple, +) + +from .validation import ( + validate_structured_data, +) + + +def dependencies(primaryType, types): + """ + Perform DFS to get all the dependencies of the primaryType + """ + deps = set() + struct_names_yet_to_be_expanded = [primaryType] + + while len(struct_names_yet_to_be_expanded) > 0: + struct_name = struct_names_yet_to_be_expanded.pop() + + deps.add(struct_name) + fields = types[struct_name] + for field in fields: + # If this struct type has already been seen, then don't expand on it' + if field["type"] in deps: + continue + # If this struct type is not a customized type, then no need to expand + elif field["type"] not in types: + continue + # Custom Struct Type + else: + struct_names_yet_to_be_expanded.append(field["type"]) + + # Don't need to make a struct as dependency of itself + deps.remove(primaryType) + + return tuple(deps) + + +def dict_to_type_name_converter(field): + """ + Given a dictionary ``field`` of type {'name': NAME, 'type': TYPE}, + this function converts it to ``TYPE NAME`` + """ + return "{0} {1}".format(field["type"], field["name"]) + + +def encode_struct(struct_name, struct_types): + return "{0}({1})".format( + struct_name, + ','.join(map(dict_to_type_name_converter, struct_types)), + ) + + +def encodeType(primaryType, types): + """ + The type of a struct is encoded as name ‖ "(" ‖ member₁ ‖ "," ‖ member₂ ‖ "," ‖ … ‖ memberₙ ")" + where each member is written as type ‖ " " ‖ name. + """ + # Getting the dependencies and sorting them alphabetically as per EIP712 + deps = dependencies(primaryType, types) + sorted_deps = (primaryType,) + tuple(sorted(deps)) + + result = ''.join( + [ + encode_struct(struct_name, types[struct_name]) + for struct_name in sorted_deps + ] + ) + return result + + +def typeHash(primaryType, types): + return keccak(text=encodeType(primaryType, types)) + + +def is_valid_abi_type(type_name): + """ + This function is used to make sure that the ``type_name`` is a valid ABI Type. + + Please note that this is a temporary function and should be replaced by the corresponding + ABI function, once the following issue has been resolved. + https://github.com/ethereum/eth-abi/issues/125 + """ + valid_abi_types = {"address", "bool", "bytes", "int", "string", "uint"} + is_bytesN = type_name.startswith("bytes") and 1 <= int(type_name[5:]) <= 32 + is_intN = ( + type_name.startswith("int") and + 8 <= int(type_name[3:]) <= 256 and + int(type_name[3:]) % 8 == 0 + ) + is_uintN = ( + type_name.startswith("uint") and + 8 <= int(type_name[4:]) <= 256 and + int(type_name[4:]) % 8 == 0 + ) + + if type_name in valid_abi_types: + return True + elif is_bytesN: + # bytes1 to bytes32 + return True + elif is_intN: + # int8 to int256 + return True + elif is_uintN: + # uint8 to uint256 + return True + + return False + + +@to_tuple +def _encodeData(primaryType, types, data): + # Add typehash + yield "bytes32", typeHash(primaryType, types) + + # Add field contents + for field in types[primaryType]: + value = data[field["name"]] + if field["type"] == "string": + if not isinstance(value, str): + raise TypeError( + "Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected " + "string value".format( + field["name"], + primaryType, + value, + type(value), + ) + ) + # Special case where the values need to be keccak hashed before they are encoded + hashed_value = keccak(text=value) + yield "bytes32", hashed_value + elif field["type"] == "bytes": + if not isinstance(value, bytes): + raise TypeError( + "Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected " + "bytes value".format( + field["name"], + primaryType, + value, + type(value), + ) + ) + # Special case where the values need to be keccak hashed before they are encoded + hashed_value = keccak(primitive=value) + yield "bytes32", hashed_value + elif field["type"] in types: + # This means that this type is a user defined type + hashed_value = keccak(primitive=encodeData(field["type"], types, value)) + yield "bytes32", hashed_value + elif field["type"][-1] == "]": + # TODO: Replace the above conditionality with Regex for identifying arrays declaration + raise NotImplementedError("TODO: Arrays currently unimplemented in encodeData") + else: + # First checking to see if type is valid as per abi + if not is_valid_abi_type(field["type"]): + raise TypeError( + "Received Invalid type `{0}` in the struct `{1}`".format( + field["type"], + primaryType, + ) + ) + + # Next see if the data fits the specified encoding type + if is_encodable(field["type"], value): + # field["type"] is a valid type and this value corresponds to that type. + yield field["type"], value + else: + raise TypeError( + "Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected " + "{4} value".format( + field["name"], + primaryType, + value, + type(value), + field["type"], + ) + ) + + +def encodeData(primaryType, types, data): + data_types_and_hashes = _encodeData(primaryType, types, data) + data_types, data_hashes = zip(*data_types_and_hashes) + return encode_abi(data_types, data_hashes) + + +def hashStruct(structured_json_string_data, is_domain_separator=False): + """ + The structured_json_string_data is expected to have the ``types`` attribute and + the ``primaryType``, ``message``, ``domain`` attribute. + The ``is_domain_separator`` variable is used to calculate the ``hashStruct`` as + part of the ``domainSeparator`` calculation. + """ + structured_data = json.loads(structured_json_string_data) + validate_structured_data(structured_data) + + types = structured_data["types"] + if is_domain_separator: + primaryType = "EIP712Domain" + data = structured_data["domain"] + else: + primaryType = structured_data["primaryType"] + data = structured_data["message"] + return keccak(encodeData(primaryType, types, data)) diff --git a/eth_account/_utils/structured_data/validation.py b/eth_account/_utils/structured_data/validation.py new file mode 100644 index 00000000..53b12612 --- /dev/null +++ b/eth_account/_utils/structured_data/validation.py @@ -0,0 +1,111 @@ +import re + +from eth_utils import ( + ValidationError, +) + +# Regexes +IDENTIFIER_REGEX = r"^[a-zA-Z_$][a-zA-Z_$0-9]*$" +TYPE_REGEX = r"^[a-zA-Z_$][a-zA-Z_$0-9]*(\[([1-9]\d*)*\])*$" + + +def validate_has_attribute(attr_name, dict_data): + if attr_name not in dict_data: + raise ValidationError( + "Attribute `{0}` not found in the JSON string". + format(attr_name) + ) + + +def validate_types_attribute(structured_data): + # Check that the data has `types` attribute + validate_has_attribute("types", structured_data) + + # Check if all the `name` and the `type` attributes in each field of all the + # `types` attribute are valid (Regex Check) + for struct_name in structured_data["types"]: + # Check that `struct_name` is of the type string + if not isinstance(struct_name, str): + raise ValidationError( + "Struct Name of `types` attribute should be a string, but got type `{0}`". + format(type(struct_name)) + ) + for field in structured_data["types"][struct_name]: + # Check that `field["name"]` is of the type string + if not isinstance(field["name"], str): + raise ValidationError( + "Field Name `{0}` of struct `{1}` should be a string, but got type `{2}`". + format(field["name"], struct_name, type(field["name"])) + ) + # Check that `field["type"]` is of the type string + if not isinstance(field["type"], str): + raise ValidationError( + "Field Type `{0}` of struct `{1}` should be a string, but got type `{2}`". + format(field["type"], struct_name, type(field["type"])) + ) + # Check that field["name"] matches with IDENTIFIER_REGEX + if not re.match(IDENTIFIER_REGEX, field["name"]): + raise ValidationError( + "Invalid Identifier `{0}` in `{1}`".format(field["name"], struct_name) + ) + # Check that field["type"] matches with TYPE_REGEX + if not re.match(TYPE_REGEX, field["type"]): + raise ValidationError( + "Invalid Type `{0}` in `{1}`".format(field["type"], struct_name) + ) + + +def validate_field_declared_only_once_in_struct(field_name, struct_data, struct_name): + if len([field for field in struct_data if field["name"] == field_name]) != 1: + raise ValidationError( + "Attribute `{0}` not declared or declared more than once in {1}". + format(field_name, struct_name) + ) + + +def validate_EIP712Domain_schema(structured_data): + # Check that the `types` attribute contains `EIP712Domain` schema declaration + if "EIP712Domain" not in structured_data["types"]: + raise ValidationError("`EIP712Domain struct` not found in types attribute") + # Check that the names and types in `EIP712Domain` are what are mentioned in the EIP-712 + # and they are declared only once + EIP712Domain_data = structured_data["types"]["EIP712Domain"] + validate_field_declared_only_once_in_struct("name", EIP712Domain_data, "EIP712Domain") + validate_field_declared_only_once_in_struct("version", EIP712Domain_data, "EIP712Domain") + validate_field_declared_only_once_in_struct("chainId", EIP712Domain_data, "EIP712Domain") + validate_field_declared_only_once_in_struct( + "verifyingContract", + EIP712Domain_data, + "EIP712Domain", + ) + + +def validate_primaryType_attribute(structured_data): + # Check that `primaryType` attribute is present + if "primaryType" not in structured_data: + raise ValidationError("The Structured Data needs to have a `primaryType` attribute") + # Check that `primaryType` value is a string + if not isinstance(structured_data["primaryType"], str): + raise ValidationError( + "Value of attribute `primaryType` should be `string`, but got type `{0}`". + format(type(structured_data["primaryType"])) + ) + # Check that the value of `primaryType` is present in the `types` attribute + if not structured_data["primaryType"] in structured_data["types"]: + raise ValidationError( + "The Primary Type `{0}` is not present in the `types` attribute". + format(structured_data["primaryType"]) + ) + + +def validate_structured_data(structured_data): + # validate the `types` attribute + validate_types_attribute(structured_data) + # validate the `EIP712Domain` struct of `types` attribute + validate_EIP712Domain_schema(structured_data) + # validate the `primaryType` attribute + validate_primaryType_attribute(structured_data) + # Check that there is a `domain` attribute in the structured data + validate_has_attribute("domain", structured_data) + # Check that there is a `message` attribute in the structured data + validate_has_attribute("message", structured_data) diff --git a/tests/core/test_structured_data_signing.py b/tests/core/test_structured_data_signing.py index a5c7d600..4820c88b 100644 --- a/tests/core/test_structured_data_signing.py +++ b/tests/core/test_structured_data_signing.py @@ -3,6 +3,7 @@ import re from eth_utils import ( + ValidationError, keccak, ) from hexbytes import ( @@ -12,14 +13,17 @@ from eth_account import ( Account, ) -from eth_account._utils.signing import ( - TYPE_REGEX, +from eth_account._utils.structured_data.hashing import ( dependencies, + encode_struct, encodeData, encodeType, hashStruct, typeHash, ) +from eth_account._utils.structured_data.validation import ( + TYPE_REGEX, +) from eth_account.messages import ( defunct_hash_message, ) @@ -99,14 +103,29 @@ def signature_kwargs(request, structured_valid_data_json_string): @pytest.mark.parametrize( 'primary_type, expected', ( - ('Mail', ['Mail', 'Person']), - ('Person', ['Person']), + ('Mail', ('Person',)), + ('Person', ()), ) ) def test_dependencies(primary_type, types, expected): assert dependencies(primary_type, types) == expected +@pytest.mark.parametrize( + 'struct_name, expected', + ( + ("Mail", "Mail(Person from,Person to,string contents)"), + ("Person", "Person(string name,address wallet)"), + ("EIP712Domain", ( + "EIP712Domain(string name,string version," + "uint256 chainId,address verifyingContract)" + )), + ) +) +def test_encode_struct(struct_name, types, expected): + assert encode_struct(struct_name, types[struct_name]) == expected + + @pytest.mark.parametrize( 'primary_type, expected', ( @@ -251,7 +270,7 @@ def test_structured_data_invalid_identifier_filtered_by_regex(): "contents": "Hello, Bob!" } }''' - with pytest.raises(AttributeError) as e: + with pytest.raises(ValidationError) as e: hashStruct(invalid_structured_data_string) assert str(e.value) == "Invalid Identifier `hello wallet` in `Person`" @@ -294,7 +313,7 @@ def test_structured_data_invalid_type_filtered_by_regex(): "contents": "Hello, Bob!" } }''' - with pytest.raises(AttributeError) as e: + with pytest.raises(ValidationError) as e: hashStruct(invalid_structured_data_string) assert str(e.value) == "Invalid Type `Hello Person` in `Mail`" From 60b073b49ea8afeabc5044fb6b27be9ce7557eab Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Fri, 22 Mar 2019 23:09:38 +0530 Subject: [PATCH 11/15] Rename function and variable names to snake case --- eth_account/_utils/signing.py | 8 ++- .../_utils/structured_data/__init__.py | 2 +- eth_account/_utils/structured_data/hashing.py | 60 +++++++++---------- tests/core/test_structured_data_signing.py | 44 +++++++------- 4 files changed, 58 insertions(+), 56 deletions(-) diff --git a/eth_account/_utils/signing.py b/eth_account/_utils/signing.py index 5b439a5c..e67a14db 100644 --- a/eth_account/_utils/signing.py +++ b/eth_account/_utils/signing.py @@ -9,7 +9,7 @@ ) from eth_account._utils.structured_data import ( - hashStruct, + hash_struct, ) from eth_account._utils.transactions import ( ChainAwareUnsignedTransaction, @@ -73,8 +73,10 @@ def signature_wrapper(message, signature_version, version_specific_data): return wrapped_message elif signature_version == STRUCTURED_DATA_SIGN_VERSION: message_string = to_text(primitive=message) - domainSeparator = hashStruct(message_string, is_domain_separator=True) - wrapped_message = b'\x19' + signature_version + domainSeparator + hashStruct(message_string) + domainSeparator = hash_struct(message_string, is_domain_separator=True) + wrapped_message = ( + b'\x19' + signature_version + domainSeparator + hash_struct(message_string) + ) return wrapped_message else: raise NotImplementedError( diff --git a/eth_account/_utils/structured_data/__init__.py b/eth_account/_utils/structured_data/__init__.py index dfcb76f7..42b7b26b 100644 --- a/eth_account/_utils/structured_data/__init__.py +++ b/eth_account/_utils/structured_data/__init__.py @@ -1,3 +1,3 @@ from .hashing import ( # noqa: F401 - hashStruct, + hash_struct, ) diff --git a/eth_account/_utils/structured_data/hashing.py b/eth_account/_utils/structured_data/hashing.py index 22e64310..e7224d25 100644 --- a/eth_account/_utils/structured_data/hashing.py +++ b/eth_account/_utils/structured_data/hashing.py @@ -14,12 +14,12 @@ ) -def dependencies(primaryType, types): +def get_dependencies(primary_type, types): """ - Perform DFS to get all the dependencies of the primaryType + Perform DFS to get all the dependencies of the primary_type """ deps = set() - struct_names_yet_to_be_expanded = [primaryType] + struct_names_yet_to_be_expanded = [primary_type] while len(struct_names_yet_to_be_expanded) > 0: struct_name = struct_names_yet_to_be_expanded.pop() @@ -27,45 +27,45 @@ def dependencies(primaryType, types): deps.add(struct_name) fields = types[struct_name] for field in fields: - # If this struct type has already been seen, then don't expand on it' - if field["type"] in deps: + if field["type"] not in types: + # We don't need to expand types that are not user defined (customized) continue - # If this struct type is not a customized type, then no need to expand - elif field["type"] not in types: + elif field["type"] in deps: + # skip types that we have already encountered continue - # Custom Struct Type else: + # Custom Struct Type struct_names_yet_to_be_expanded.append(field["type"]) # Don't need to make a struct as dependency of itself - deps.remove(primaryType) + deps.remove(primary_type) return tuple(deps) -def dict_to_type_name_converter(field): +def field_identifier(field): """ - Given a dictionary ``field`` of type {'name': NAME, 'type': TYPE}, + Given a ``field`` of the format {'name': NAME, 'type': TYPE}, this function converts it to ``TYPE NAME`` """ return "{0} {1}".format(field["type"], field["name"]) -def encode_struct(struct_name, struct_types): +def encode_struct(struct_name, struct_field_types): return "{0}({1})".format( struct_name, - ','.join(map(dict_to_type_name_converter, struct_types)), + ','.join(map(field_identifier, struct_field_types)), ) -def encodeType(primaryType, types): +def encode_type(primary_type, types): """ The type of a struct is encoded as name ‖ "(" ‖ member₁ ‖ "," ‖ member₂ ‖ "," ‖ … ‖ memberₙ ")" where each member is written as type ‖ " " ‖ name. """ # Getting the dependencies and sorting them alphabetically as per EIP712 - deps = dependencies(primaryType, types) - sorted_deps = (primaryType,) + tuple(sorted(deps)) + deps = get_dependencies(primary_type, types) + sorted_deps = (primary_type,) + tuple(sorted(deps)) result = ''.join( [ @@ -76,8 +76,8 @@ def encodeType(primaryType, types): return result -def typeHash(primaryType, types): - return keccak(text=encodeType(primaryType, types)) +def hash_struct_type(primary_type, types): + return keccak(text=encode_type(primary_type, types)) def is_valid_abi_type(type_name): @@ -117,12 +117,12 @@ def is_valid_abi_type(type_name): @to_tuple -def _encodeData(primaryType, types, data): +def _encode_data(primary_type, types, data): # Add typehash - yield "bytes32", typeHash(primaryType, types) + yield "bytes32", hash_struct_type(primary_type, types) # Add field contents - for field in types[primaryType]: + for field in types[primary_type]: value = data[field["name"]] if field["type"] == "string": if not isinstance(value, str): @@ -130,7 +130,7 @@ def _encodeData(primaryType, types, data): "Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected " "string value".format( field["name"], - primaryType, + primary_type, value, type(value), ) @@ -144,7 +144,7 @@ def _encodeData(primaryType, types, data): "Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected " "bytes value".format( field["name"], - primaryType, + primary_type, value, type(value), ) @@ -154,7 +154,7 @@ def _encodeData(primaryType, types, data): yield "bytes32", hashed_value elif field["type"] in types: # This means that this type is a user defined type - hashed_value = keccak(primitive=encodeData(field["type"], types, value)) + hashed_value = keccak(primitive=encode_data(field["type"], types, value)) yield "bytes32", hashed_value elif field["type"][-1] == "]": # TODO: Replace the above conditionality with Regex for identifying arrays declaration @@ -165,7 +165,7 @@ def _encodeData(primaryType, types, data): raise TypeError( "Received Invalid type `{0}` in the struct `{1}`".format( field["type"], - primaryType, + primary_type, ) ) @@ -178,7 +178,7 @@ def _encodeData(primaryType, types, data): "Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected " "{4} value".format( field["name"], - primaryType, + primary_type, value, type(value), field["type"], @@ -186,13 +186,13 @@ def _encodeData(primaryType, types, data): ) -def encodeData(primaryType, types, data): - data_types_and_hashes = _encodeData(primaryType, types, data) +def encode_data(primaryType, types, data): + data_types_and_hashes = _encode_data(primaryType, types, data) data_types, data_hashes = zip(*data_types_and_hashes) return encode_abi(data_types, data_hashes) -def hashStruct(structured_json_string_data, is_domain_separator=False): +def hash_struct(structured_json_string_data, is_domain_separator=False): """ The structured_json_string_data is expected to have the ``types`` attribute and the ``primaryType``, ``message``, ``domain`` attribute. @@ -209,4 +209,4 @@ def hashStruct(structured_json_string_data, is_domain_separator=False): else: primaryType = structured_data["primaryType"] data = structured_data["message"] - return keccak(encodeData(primaryType, types, data)) + return keccak(encode_data(primaryType, types, data)) diff --git a/tests/core/test_structured_data_signing.py b/tests/core/test_structured_data_signing.py index 4820c88b..2bbc3dcc 100644 --- a/tests/core/test_structured_data_signing.py +++ b/tests/core/test_structured_data_signing.py @@ -14,12 +14,12 @@ Account, ) from eth_account._utils.structured_data.hashing import ( - dependencies, + encode_data, encode_struct, - encodeData, - encodeType, - hashStruct, - typeHash, + encode_type, + get_dependencies, + hash_struct, + hash_struct_type, ) from eth_account._utils.structured_data.validation import ( TYPE_REGEX, @@ -107,8 +107,8 @@ def signature_kwargs(request, structured_valid_data_json_string): ('Person', ()), ) ) -def test_dependencies(primary_type, types, expected): - assert dependencies(primary_type, types) == expected +def test_get_dependencies(primary_type, types, expected): + assert get_dependencies(primary_type, types) == expected @pytest.mark.parametrize( @@ -133,8 +133,8 @@ def test_encode_struct(struct_name, types, expected): ('Person', 'Person(string name,address wallet)'), ) ) -def test_encodeType(primary_type, types, expected): - assert encodeType(primary_type, types) == expected +def test_encode_type(primary_type, types, expected): + assert encode_type(primary_type, types) == expected @pytest.mark.parametrize( @@ -144,29 +144,29 @@ def test_encodeType(primary_type, types, expected): ('Person', 'b9d8c78acf9b987311de6c7b45bb6a9c8e1bf361fa7fd3467a2163f994c79500'), ) ) -def test_typeHash(primary_type, types, expected_hex_value): - assert typeHash(primary_type, types).hex() == expected_hex_value +def test_hash_struct_type(primary_type, types, expected_hex_value): + assert hash_struct_type(primary_type, types).hex() == expected_hex_value -def test_encodeData(types, message): +def test_encode_data(types, message): primary_type = "Mail" expected_hex_value = ( "a0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2fc71e5fa27ff56c350aa531b" "c129ebdf613b772b6604664f5d8dbe21b85eb0c8cd54f074a4af31b4411ff6a60c9719dbd559c221c8ac3492d9" "d872b041d703d1b5aadf3154a261abdd9086fc627b61efca26ae5702701d05cd2305f7c52a2fc8" ) - assert encodeData(primary_type, types, message).hex() == expected_hex_value + assert encode_data(primary_type, types, message).hex() == expected_hex_value -def test_hashStruct_main_message(structured_valid_data_json_string): +def test_hash_struct_main_message(structured_valid_data_json_string): expected_hex_value = "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e" - assert hashStruct(structured_valid_data_json_string).hex() == expected_hex_value + assert hash_struct(structured_valid_data_json_string).hex() == expected_hex_value -def test_hashStruct_domain(structured_valid_data_json_string): +def test_hash_struct_domain(structured_valid_data_json_string): expected_hex_value = "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f" assert ( - hashStruct(structured_valid_data_json_string, is_domain_separator=True).hex() == + hash_struct(structured_valid_data_json_string, is_domain_separator=True).hex() == expected_hex_value ) @@ -271,7 +271,7 @@ def test_structured_data_invalid_identifier_filtered_by_regex(): } }''' with pytest.raises(ValidationError) as e: - hashStruct(invalid_structured_data_string) + hash_struct(invalid_structured_data_string) assert str(e.value) == "Invalid Identifier `hello wallet` in `Person`" @@ -314,7 +314,7 @@ def test_structured_data_invalid_type_filtered_by_regex(): } }''' with pytest.raises(ValidationError) as e: - hashStruct(invalid_structured_data_string) + hash_struct(invalid_structured_data_string) assert str(e.value) == "Invalid Type `Hello Person` in `Mail`" @@ -358,7 +358,7 @@ def test_invalid_structured_data_value_type_mismatch_in_primary_type(): } }''' with pytest.raises(TypeError) as e: - hashStruct(invalid_structured_data_string) + hash_struct(invalid_structured_data_string) assert ( str(e.value) == "Value of `contents` (12345) in the struct `Mail` is of the " "type ``, but expected string value" @@ -405,7 +405,7 @@ def test_invalid_structured_data_invalid_abi_type(): } }''' with pytest.raises(TypeError) as e: - hashStruct(invalid_structured_data_string) + hash_struct(invalid_structured_data_string) assert str(e.value) == "Received Invalid type `uint25689` in the struct `Person`" @@ -450,7 +450,7 @@ def test_structured_data_invalid_identifier_filtered_by_abi_encodable_function() } }''' with pytest.raises(TypeError) as e: - hashStruct(invalid_structured_data_string) + hash_struct(invalid_structured_data_string) assert ( str(e.value) == "Value of `balance` (how do you do?) in the struct `Person` is of the " "type ``, but expected uint256 value" From 8c116729c0cdae7863207d7ad9a12e667a4018ee Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Sat, 23 Mar 2019 10:51:21 +0530 Subject: [PATCH 12/15] Move the JSON Message strings from test files to fixtures --- tests/core/test_structured_data_signing.py | 238 ++---------------- .../invalid_message_invalid_abi_type.json | 37 +++ ..._message_valid_abi_type_invalid_value.json | 37 +++ ...sage_value_type_mismatch_primary_type.json | 37 +++ .../invalid_struct_identifier_message.json | 37 +++ .../fixtures/invalid_struct_type_message.json | 37 +++ tests/fixtures/valid_message.json | 37 +++ 7 files changed, 238 insertions(+), 222 deletions(-) create mode 100644 tests/fixtures/invalid_message_invalid_abi_type.json create mode 100644 tests/fixtures/invalid_message_valid_abi_type_invalid_value.json create mode 100644 tests/fixtures/invalid_message_value_type_mismatch_primary_type.json create mode 100644 tests/fixtures/invalid_struct_identifier_message.json create mode 100644 tests/fixtures/invalid_struct_type_message.json create mode 100644 tests/fixtures/valid_message.json diff --git a/tests/core/test_structured_data_signing.py b/tests/core/test_structured_data_signing.py index 2bbc3dcc..1fa4fb26 100644 --- a/tests/core/test_structured_data_signing.py +++ b/tests/core/test_structured_data_signing.py @@ -31,43 +31,7 @@ @pytest.fixture def structured_valid_data_json_string(): - return '''{ - "types": { - "EIP712Domain": [ - {"name": "name", "type": "string"}, - {"name": "version", "type": "string"}, - {"name": "chainId", "type": "uint256"}, - {"name": "verifyingContract", "type": "address"} - ], - "Person": [ - {"name": "name", "type": "string"}, - {"name": "wallet", "type": "address"} - ], - "Mail": [ - {"name": "from", "type": "Person"}, - {"name": "to", "type": "Person"}, - {"name": "contents", "type": "string"} - ] - }, - "primaryType": "Mail", - "domain": { - "name": "Ether Mail", - "version": "1", - "chainId": 1, - "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" - }, - "message": { - "from": { - "name": "Cow", - "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" - }, - "to": { - "name": "Bob", - "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" - }, - "contents": "Hello, Bob!" - } - }''' + return open("tests/fixtures/valid_message.json", "r").read() @pytest.fixture @@ -233,86 +197,18 @@ def test_type_regex(type, valid): def test_structured_data_invalid_identifier_filtered_by_regex(): - invalid_structured_data_string = '''{ - "types": { - "EIP712Domain": [ - {"name": "name", "type": "string"}, - {"name": "version", "type": "string"}, - {"name": "chainId", "type": "uint256"}, - {"name": "verifyingContract", "type": "address"} - ], - "Person": [ - {"name": "name", "type": "string"}, - {"name": "hello wallet", "type": "address"} - ], - "Mail": [ - {"name": "from", "type": "Person"}, - {"name": "to", "type": "Person"}, - {"name": "contents", "type": "string"} - ] - }, - "primaryType": "Mail", - "domain": { - "name": "Ether Mail", - "version": "1", - "chainId": 1, - "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" - }, - "message": { - "from": { - "name": "Cow", - "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" - }, - "to": { - "name": "Bob", - "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" - }, - "contents": "Hello, Bob!" - } - }''' + invalid_structured_data_string = open( + "tests/fixtures/invalid_struct_identifier_message.json" + ).read() with pytest.raises(ValidationError) as e: hash_struct(invalid_structured_data_string) assert str(e.value) == "Invalid Identifier `hello wallet` in `Person`" def test_structured_data_invalid_type_filtered_by_regex(): - invalid_structured_data_string = '''{ - "types": { - "EIP712Domain": [ - {"name": "name", "type": "string"}, - {"name": "version", "type": "string"}, - {"name": "chainId", "type": "uint256"}, - {"name": "verifyingContract", "type": "address"} - ], - "Person": [ - {"name": "name", "type": "string"}, - {"name": "wallet", "type": "address"} - ], - "Mail": [ - {"name": "from", "type": "Hello Person"}, - {"name": "to", "type": "Person"}, - {"name": "contents", "type": "string"} - ] - }, - "primaryType": "Mail", - "domain": { - "name": "Ether Mail", - "version": "1", - "chainId": 1, - "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" - }, - "message": { - "from": { - "name": "Cow", - "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" - }, - "to": { - "name": "Bob", - "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" - }, - "contents": "Hello, Bob!" - } - }''' + invalid_structured_data_string = open( + "tests/fixtures/invalid_struct_type_message.json" + ).read() with pytest.raises(ValidationError) as e: hash_struct(invalid_structured_data_string) assert str(e.value) == "Invalid Type `Hello Person` in `Mail`" @@ -320,43 +216,9 @@ def test_structured_data_invalid_type_filtered_by_regex(): def test_invalid_structured_data_value_type_mismatch_in_primary_type(): # Given type is valid (string), but the value (int) is not of the mentioned type - invalid_structured_data_string = '''{ - "types": { - "EIP712Domain": [ - {"name": "name", "type": "string"}, - {"name": "version", "type": "string"}, - {"name": "chainId", "type": "uint256"}, - {"name": "verifyingContract", "type": "address"} - ], - "Person": [ - {"name": "name", "type": "string"}, - {"name": "wallet", "type": "address"} - ], - "Mail": [ - {"name": "from", "type": "Person"}, - {"name": "to", "type": "Person"}, - {"name": "contents", "type": "string"} - ] - }, - "primaryType": "Mail", - "domain": { - "name": "Ether Mail", - "version": "1", - "chainId": 1, - "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" - }, - "message": { - "from": { - "name": "Cow", - "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" - }, - "to": { - "name": "Bob", - "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" - }, - "contents": 12345 - } - }''' + invalid_structured_data_string = open( + "tests/fixtures/invalid_message_value_type_mismatch_primary_type.json" + ).read() with pytest.raises(TypeError) as e: hash_struct(invalid_structured_data_string) assert ( @@ -367,43 +229,9 @@ def test_invalid_structured_data_value_type_mismatch_in_primary_type(): def test_invalid_structured_data_invalid_abi_type(): # Given type/types are invalid - invalid_structured_data_string = '''{ - "types": { - "EIP712Domain": [ - {"name": "name", "type": "string"}, - {"name": "version", "type": "string"}, - {"name": "chainId", "type": "uint256"}, - {"name": "verifyingContract", "type": "address"} - ], - "Person": [ - {"name": "name", "type": "string"}, - {"name": "balance", "type": "uint25689"} - ], - "Mail": [ - {"name": "from", "type": "Person"}, - {"name": "to", "type": "Person"}, - {"name": "contents", "type": "string"} - ] - }, - "primaryType": "Mail", - "domain": { - "name": "Ether Mail", - "version": "1", - "chainId": 1, - "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" - }, - "message": { - "from": { - "name": "Cow", - "balance": 123 - }, - "to": { - "name": "Bob", - "balance": 1234 - }, - "contents": "Hello Bob!" - } - }''' + invalid_structured_data_string = open( + "tests/fixtures/invalid_message_invalid_abi_type.json" + ).read() with pytest.raises(TypeError) as e: hash_struct(invalid_structured_data_string) assert str(e.value) == "Received Invalid type `uint25689` in the struct `Person`" @@ -412,43 +240,9 @@ def test_invalid_structured_data_invalid_abi_type(): def test_structured_data_invalid_identifier_filtered_by_abi_encodable_function(): # Given valid abi type, but the value is not of the specified type # (Error is found by the ``is_encodable`` ABI function) - invalid_structured_data_string = '''{ - "types": { - "EIP712Domain": [ - {"name": "name", "type": "string"}, - {"name": "version", "type": "string"}, - {"name": "chainId", "type": "uint256"}, - {"name": "verifyingContract", "type": "address"} - ], - "Person": [ - {"name": "name", "type": "string"}, - {"name": "balance", "type": "uint256"} - ], - "Mail": [ - {"name": "from", "type": "Person"}, - {"name": "to", "type": "Person"}, - {"name": "contents", "type": "string"} - ] - }, - "primaryType": "Mail", - "domain": { - "name": "Ether Mail", - "version": "1", - "chainId": 1, - "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" - }, - "message": { - "from": { - "name": "Cow", - "balance": 1234 - }, - "to": { - "name": "Bob", - "balance": "how do you do?" - }, - "contents": "Hello Bob!" - } - }''' + invalid_structured_data_string = open( + "tests/fixtures/invalid_message_valid_abi_type_invalid_value.json" + ).read() with pytest.raises(TypeError) as e: hash_struct(invalid_structured_data_string) assert ( diff --git a/tests/fixtures/invalid_message_invalid_abi_type.json b/tests/fixtures/invalid_message_invalid_abi_type.json new file mode 100644 index 00000000..09047375 --- /dev/null +++ b/tests/fixtures/invalid_message_invalid_abi_type.json @@ -0,0 +1,37 @@ +{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "Person": [ + {"name": "name", "type": "string"}, + {"name": "balance", "type": "uint25689"} + ], + "Mail": [ + {"name": "from", "type": "Person"}, + {"name": "to", "type": "Person"}, + {"name": "contents", "type": "string"} + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "balance": 123 + }, + "to": { + "name": "Bob", + "balance": 1234 + }, + "contents": "Hello Bob!" + } +} diff --git a/tests/fixtures/invalid_message_valid_abi_type_invalid_value.json b/tests/fixtures/invalid_message_valid_abi_type_invalid_value.json new file mode 100644 index 00000000..06c69b97 --- /dev/null +++ b/tests/fixtures/invalid_message_valid_abi_type_invalid_value.json @@ -0,0 +1,37 @@ +{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "Person": [ + {"name": "name", "type": "string"}, + {"name": "balance", "type": "uint256"} + ], + "Mail": [ + {"name": "from", "type": "Person"}, + {"name": "to", "type": "Person"}, + {"name": "contents", "type": "string"} + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "balance": 1234 + }, + "to": { + "name": "Bob", + "balance": "how do you do?" + }, + "contents": "Hello Bob!" + } +} diff --git a/tests/fixtures/invalid_message_value_type_mismatch_primary_type.json b/tests/fixtures/invalid_message_value_type_mismatch_primary_type.json new file mode 100644 index 00000000..e260bb56 --- /dev/null +++ b/tests/fixtures/invalid_message_value_type_mismatch_primary_type.json @@ -0,0 +1,37 @@ +{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "Person": [ + {"name": "name", "type": "string"}, + {"name": "wallet", "type": "address"} + ], + "Mail": [ + {"name": "from", "type": "Person"}, + {"name": "to", "type": "Person"}, + {"name": "contents", "type": "string"} + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents": 12345 + } +} diff --git a/tests/fixtures/invalid_struct_identifier_message.json b/tests/fixtures/invalid_struct_identifier_message.json new file mode 100644 index 00000000..4d83c97c --- /dev/null +++ b/tests/fixtures/invalid_struct_identifier_message.json @@ -0,0 +1,37 @@ +{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "Person": [ + {"name": "name", "type": "string"}, + {"name": "hello wallet", "type": "address"} + ], + "Mail": [ + {"name": "from", "type": "Person"}, + {"name": "to", "type": "Person"}, + {"name": "contents", "type": "string"} + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents": "Hello, Bob!" + } +} diff --git a/tests/fixtures/invalid_struct_type_message.json b/tests/fixtures/invalid_struct_type_message.json new file mode 100644 index 00000000..8372e830 --- /dev/null +++ b/tests/fixtures/invalid_struct_type_message.json @@ -0,0 +1,37 @@ +{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "Person": [ + {"name": "name", "type": "string"}, + {"name": "wallet", "type": "address"} + ], + "Mail": [ + {"name": "from", "type": "Hello Person"}, + {"name": "to", "type": "Person"}, + {"name": "contents", "type": "string"} + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents": "Hello, Bob!" + } +} diff --git a/tests/fixtures/valid_message.json b/tests/fixtures/valid_message.json new file mode 100644 index 00000000..75f23df6 --- /dev/null +++ b/tests/fixtures/valid_message.json @@ -0,0 +1,37 @@ +{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "Person": [ + {"name": "name", "type": "string"}, + {"name": "wallet", "type": "address"} + ], + "Mail": [ + {"name": "from", "type": "Person"}, + {"name": "to", "type": "Person"}, + {"name": "contents", "type": "string"} + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents": "Hello, Bob!" + } +} From a612942909d39101fe1fbec8fc6f7491a6620ff1 Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Sat, 23 Mar 2019 19:14:13 +0530 Subject: [PATCH 13/15] Add support for array data types --- eth_account/_utils/structured_data/hashing.py | 72 ++++++++++++++++++- setup.py | 2 +- tests/core/test_structured_data_signing.py | 14 ++++ 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/eth_account/_utils/structured_data/hashing.py b/eth_account/_utils/structured_data/hashing.py index e7224d25..15eaad27 100644 --- a/eth_account/_utils/structured_data/hashing.py +++ b/eth_account/_utils/structured_data/hashing.py @@ -1,9 +1,15 @@ +from collections import ( + Iterable, +) import json from eth_abi import ( encode_abi, is_encodable, ) +from eth_abi.grammar import ( + parse, +) from eth_utils import ( keccak, to_tuple, @@ -116,6 +122,41 @@ def is_valid_abi_type(type_name): return False +def is_array_type(type): + # Identify if type such as "person[]" or "person[2]" is an array + abi_type = parse(type) + return abi_type.is_array + + +def get_array_dimensions(data): + """ + Given an array type data item, check that it is an array and + return the dimensions as a tuple. + Ex: get_array_dimensions([[1, 2, 3], [4, 5, 6]]) returns (2, 3) + """ + if not isinstance(data, Iterable) or isinstance(data, str): + # Because even strings are considered Iterables as per python + return () + + expected_dimensions = get_array_dimensions(data[0]) + for index in range(1, len(data)): + # 1 dimension less sub-arrays should all have the same dimensions to be a valid array + if get_array_dimensions(data[index]) != expected_dimensions: + raise TypeError("Not a valid array or incomplete array") + + return (len(data),) + expected_dimensions + + +@to_tuple +def flatten_multidimensional_array(array): + for item in array: + if isinstance(item, Iterable) and not isinstance(item, str): + for x in flatten_multidimensional_array(item): + yield x + else: + yield item + + @to_tuple def _encode_data(primary_type, types, data): # Add typehash @@ -156,9 +197,34 @@ def _encode_data(primary_type, types, data): # This means that this type is a user defined type hashed_value = keccak(primitive=encode_data(field["type"], types, value)) yield "bytes32", hashed_value - elif field["type"][-1] == "]": - # TODO: Replace the above conditionality with Regex for identifying arrays declaration - raise NotImplementedError("TODO: Arrays currently unimplemented in encodeData") + elif is_array_type(field["type"]): + # Get the dimensions from the value + array_dimensions = get_array_dimensions(value) + # Get the dimensions from what was declared in the schema + parsed_type = parse(field["type"]) + for i in range(len(array_dimensions)): + if len(parsed_type.arrlist[i]) == 0: + # Skip empty or dynamically declared dimensions + continue + if array_dimensions[i] != parsed_type.arrlist[i][0]: + # Dimensions should match with declared schema + raise TypeError( + "Array data `{0}` has dimensions `{1}` whereas the " + "schema has dimensions `{2}`".format( + value, + array_dimensions, + parsed_type.arrlist, + ) + ) + + array_items = flatten_multidimensional_array(value) + array_items_encoding = [ + encode_data(parsed_type.base, types, array_item) + for array_item in array_items + ] + concatenated_array_encodings = ''.join(array_items_encoding) + hashed_value = keccak(concatenated_array_encodings) + yield "bytes32", hashed_value else: # First checking to see if type is valid as per abi if not is_valid_abi_type(field["type"]): diff --git a/setup.py b/setup.py index 8f6b7528..1a0346d5 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ include_package_data=True, install_requires=[ "attrdict>=2.0.0,<3", - "eth-abi>=1.0.0,<2.0.0", + "eth-abi>=2.0.0b6,<3", "eth-keyfile>=0.5.0,<0.6.0", "eth-keys>=0.2.0b3,<0.3.0", "eth-utils>=1.0.2,<2", diff --git a/tests/core/test_structured_data_signing.py b/tests/core/test_structured_data_signing.py index 1fa4fb26..ce99550a 100644 --- a/tests/core/test_structured_data_signing.py +++ b/tests/core/test_structured_data_signing.py @@ -17,6 +17,7 @@ encode_data, encode_struct, encode_type, + get_array_dimensions, get_dependencies, hash_struct, hash_struct_type, @@ -249,3 +250,16 @@ def test_structured_data_invalid_identifier_filtered_by_abi_encodable_function() str(e.value) == "Value of `balance` (how do you do?) in the struct `Person` is of the " "type ``, but expected uint256 value" ) + + +@pytest.mark.parametrize( + 'data, expected', + ( + ([[1, 2, 3], [4, 5, 6]], (2, 3)), + ([[1, 2, 3]], (1, 3)), + ([1, 2, 3], (3,)), + ([[[1, 2], [3, 4], [5, 6]], [[7, 8], [9, 10], [11, 12]]], (2, 3, 2)), + ) +) +def test_get_array_dimensions(data, expected): + assert get_array_dimensions(data) == expected From 8aa11c1d4006a9a24fe250a2ae8375e7a827e54b Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Mon, 25 Mar 2019 12:50:43 +0530 Subject: [PATCH 14/15] Add tests for array data checking --- eth_account/_utils/structured_data/hashing.py | 7 ++-- setup.py | 2 +- tests/core/test_structured_data_signing.py | 26 +++++++++++++ ...alid_message_unequal_1d_array_lengths.json | 37 +++++++++++++++++++ ...alid_message_unequal_array_dimensions.json | 37 +++++++++++++++++++ 5 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/invalid_message_unequal_1d_array_lengths.json create mode 100644 tests/fixtures/invalid_message_unequal_array_dimensions.json diff --git a/eth_account/_utils/structured_data/hashing.py b/eth_account/_utils/structured_data/hashing.py index 15eaad27..aeaf85ca 100644 --- a/eth_account/_utils/structured_data/hashing.py +++ b/eth_account/_utils/structured_data/hashing.py @@ -134,8 +134,9 @@ def get_array_dimensions(data): return the dimensions as a tuple. Ex: get_array_dimensions([[1, 2, 3], [4, 5, 6]]) returns (2, 3) """ - if not isinstance(data, Iterable) or isinstance(data, str): - # Because even strings are considered Iterables as per python + if not isinstance(data, list) and not isinstance(data, tuple): + # Not checking for Iterable instance, because even Dictionaries and strings + # are considered as iterables, but that's not what we want the condition to be. return () expected_dimensions = get_array_dimensions(data[0]) @@ -213,7 +214,7 @@ def _encode_data(primary_type, types, data): "schema has dimensions `{2}`".format( value, array_dimensions, - parsed_type.arrlist, + tuple(map(lambda x: x[0], parsed_type.arrlist)), ) ) diff --git a/setup.py b/setup.py index 1a0346d5..931f6a7a 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ include_package_data=True, install_requires=[ "attrdict>=2.0.0,<3", - "eth-abi>=2.0.0b6,<3", + "eth-abi>=2.0.0b7,<3", "eth-keyfile>=0.5.0,<0.6.0", "eth-keys>=0.2.0b3,<0.3.0", "eth-utils>=1.0.2,<2", diff --git a/tests/core/test_structured_data_signing.py b/tests/core/test_structured_data_signing.py index ce99550a..608256c8 100644 --- a/tests/core/test_structured_data_signing.py +++ b/tests/core/test_structured_data_signing.py @@ -263,3 +263,29 @@ def test_structured_data_invalid_identifier_filtered_by_abi_encodable_function() ) def test_get_array_dimensions(data, expected): assert get_array_dimensions(data) == expected + + +def test_unequal_array_lengths_between_schema_and_data(): + invalid_structured_data_string = open( + "tests/fixtures/invalid_message_unequal_1d_array_lengths.json" + ).read() + with pytest.raises(TypeError) as e: + hash_struct(invalid_structured_data_string) + assert ( + str(e.value) == "Array data " + "`[{'name': 'Bob', 'wallet': '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB'}]` has " + "dimensions `(1,)` whereas the schema has dimensions `(2,)`" + ) + + +def test_unequal_array_dimension_between_schema_and_data(): + invalid_structured_data_string = open( + "tests/fixtures/invalid_message_unequal_array_dimensions.json" + ).read() + with pytest.raises(TypeError) as e: + hash_struct(invalid_structured_data_string) + assert ( + str(e.value) == "Array data " + "`[{'name': 'Bob', 'wallet': '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB'}]` has " + "dimensions `(1,)` whereas the schema has dimensions `(2, 3, 4)`" + ) diff --git a/tests/fixtures/invalid_message_unequal_1d_array_lengths.json b/tests/fixtures/invalid_message_unequal_1d_array_lengths.json new file mode 100644 index 00000000..4bd2c774 --- /dev/null +++ b/tests/fixtures/invalid_message_unequal_1d_array_lengths.json @@ -0,0 +1,37 @@ +{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "person": [ + {"name": "name", "type": "string"}, + {"name": "wallet", "type": "address"} + ], + "Mail": [ + {"name": "from", "type": "person"}, + {"name": "to", "type": "person[2]"}, + {"name": "contents", "type": "string"} + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": [{ + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }], + "contents": "Hello, Bob!" + } +} diff --git a/tests/fixtures/invalid_message_unequal_array_dimensions.json b/tests/fixtures/invalid_message_unequal_array_dimensions.json new file mode 100644 index 00000000..345f7df7 --- /dev/null +++ b/tests/fixtures/invalid_message_unequal_array_dimensions.json @@ -0,0 +1,37 @@ +{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "person": [ + {"name": "name", "type": "string"}, + {"name": "wallet", "type": "address"} + ], + "Mail": [ + {"name": "from", "type": "person"}, + {"name": "to", "type": "person[2][3][4]"}, + {"name": "contents", "type": "string"} + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": [{ + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }], + "contents": "Hello, Bob!" + } +} From 8880e3812305e29da528b47fb857a22ea9d5f5d7 Mon Sep 17 00:00:00 2001 From: Bhargavasomu Date: Tue, 26 Mar 2019 21:16:56 +0530 Subject: [PATCH 15/15] Improve performance of get_array_dimensions and tweak API --- eth_account/_utils/signing.py | 11 +- .../_utils/structured_data/__init__.py | 3 - eth_account/_utils/structured_data/hashing.py | 110 +++++++++++++----- tests/core/test_structured_data_signing.py | 38 +++--- 4 files changed, 111 insertions(+), 51 deletions(-) diff --git a/eth_account/_utils/signing.py b/eth_account/_utils/signing.py index e67a14db..7f30a66f 100644 --- a/eth_account/_utils/signing.py +++ b/eth_account/_utils/signing.py @@ -8,8 +8,10 @@ to_text, ) -from eth_account._utils.structured_data import ( - hash_struct, +from eth_account._utils.structured_data.hashing import ( + hash_domain, + hash_message, + load_and_validate_structured_message, ) from eth_account._utils.transactions import ( ChainAwareUnsignedTransaction, @@ -73,9 +75,10 @@ def signature_wrapper(message, signature_version, version_specific_data): return wrapped_message elif signature_version == STRUCTURED_DATA_SIGN_VERSION: message_string = to_text(primitive=message) - domainSeparator = hash_struct(message_string, is_domain_separator=True) + structured_data = load_and_validate_structured_message(message_string) + domainSeparator = hash_domain(structured_data) wrapped_message = ( - b'\x19' + signature_version + domainSeparator + hash_struct(message_string) + b'\x19' + signature_version + domainSeparator + hash_message(structured_data) ) return wrapped_message else: diff --git a/eth_account/_utils/structured_data/__init__.py b/eth_account/_utils/structured_data/__init__.py index 42b7b26b..e69de29b 100644 --- a/eth_account/_utils/structured_data/__init__.py +++ b/eth_account/_utils/structured_data/__init__.py @@ -1,3 +0,0 @@ -from .hashing import ( # noqa: F401 - hash_struct, -) diff --git a/eth_account/_utils/structured_data/hashing.py b/eth_account/_utils/structured_data/hashing.py index aeaf85ca..8de5716b 100644 --- a/eth_account/_utils/structured_data/hashing.py +++ b/eth_account/_utils/structured_data/hashing.py @@ -1,7 +1,10 @@ -from collections import ( - Iterable, +from itertools import ( + groupby, ) import json +from operator import ( + itemgetter, +) from eth_abi import ( encode_abi, @@ -11,8 +14,10 @@ parse, ) from eth_utils import ( + ValidationError, keccak, to_tuple, + toolz, ) from .validation import ( @@ -128,32 +133,68 @@ def is_array_type(type): return abi_type.is_array -def get_array_dimensions(data): +@to_tuple +def get_depths_and_dimensions(data, depth): """ - Given an array type data item, check that it is an array and - return the dimensions as a tuple. - Ex: get_array_dimensions([[1, 2, 3], [4, 5, 6]]) returns (2, 3) + Yields 2-length tuples of depth and dimension of each element at that depth """ - if not isinstance(data, list) and not isinstance(data, tuple): + if not isinstance(data, (list, tuple)): # Not checking for Iterable instance, because even Dictionaries and strings # are considered as iterables, but that's not what we want the condition to be. return () - expected_dimensions = get_array_dimensions(data[0]) - for index in range(1, len(data)): - # 1 dimension less sub-arrays should all have the same dimensions to be a valid array - if get_array_dimensions(data[index]) != expected_dimensions: - raise TypeError("Not a valid array or incomplete array") + yield depth, len(data) - return (len(data),) + expected_dimensions + for item in data: + # iterating over all 1 dimension less sub-data items + yield from get_depths_and_dimensions(item, depth + 1) + + +def get_array_dimensions(data): + """ + Given an array type data item, check that it is an array and + return the dimensions as a tuple. + Ex: get_array_dimensions([[1, 2, 3], [4, 5, 6]]) returns (2, 3) + """ + depths_and_dimensions = get_depths_and_dimensions(data, 0) + # re-form as a dictionary with `depth` as key, and all of the dimensions found at that depth. + grouped_by_depth = { + depth: tuple(dimension for depth, dimension in group) + for depth, group in groupby(depths_and_dimensions, itemgetter(0)) + } + + # validate that there is only one dimension for any given depth. + invalid_depths_dimensions = tuple( + (depth, dimensions) + for depth, dimensions in grouped_by_depth.items() + if len(set(dimensions)) != 1 + ) + if invalid_depths_dimensions: + raise ValidationError( + '\n'.join( + [ + "Depth {0} of array data has more than one dimensions: {1}". + format(depth, dimensions) + for depth, dimensions in invalid_depths_dimensions + ] + ) + ) + + dimensions = tuple( + toolz.first(set(dimensions)) + for depth, dimensions in sorted(grouped_by_depth.items()) + ) + + return dimensions @to_tuple def flatten_multidimensional_array(array): for item in array: - if isinstance(item, Iterable) and not isinstance(item, str): - for x in flatten_multidimensional_array(item): - yield x + if not isinstance(item, (list, tuple)): + # Not checking for Iterable instance, because even Dictionaries and strings + # are considered as iterables, but that's not what we want the condition to be. + yield from flatten_multidimensional_array(item) else: yield item @@ -259,21 +300,28 @@ def encode_data(primaryType, types, data): return encode_abi(data_types, data_hashes) -def hash_struct(structured_json_string_data, is_domain_separator=False): - """ - The structured_json_string_data is expected to have the ``types`` attribute and - the ``primaryType``, ``message``, ``domain`` attribute. - The ``is_domain_separator`` variable is used to calculate the ``hashStruct`` as - part of the ``domainSeparator`` calculation. - """ +def load_and_validate_structured_message(structured_json_string_data): structured_data = json.loads(structured_json_string_data) validate_structured_data(structured_data) - types = structured_data["types"] - if is_domain_separator: - primaryType = "EIP712Domain" - data = structured_data["domain"] - else: - primaryType = structured_data["primaryType"] - data = structured_data["message"] - return keccak(encode_data(primaryType, types, data)) + return structured_data + + +def hash_domain(structured_data): + return keccak( + encode_data( + "EIP712Domain", + structured_data["types"], + structured_data["domain"] + ) + ) + + +def hash_message(structured_data): + return keccak( + encode_data( + structured_data["primaryType"], + structured_data["types"], + structured_data["message"] + ) + ) diff --git a/tests/core/test_structured_data_signing.py b/tests/core/test_structured_data_signing.py index 608256c8..2360c004 100644 --- a/tests/core/test_structured_data_signing.py +++ b/tests/core/test_structured_data_signing.py @@ -19,8 +19,10 @@ encode_type, get_array_dimensions, get_dependencies, - hash_struct, + hash_domain, + hash_message, hash_struct_type, + load_and_validate_structured_message, ) from eth_account._utils.structured_data.validation import ( TYPE_REGEX, @@ -124,16 +126,15 @@ def test_encode_data(types, message): def test_hash_struct_main_message(structured_valid_data_json_string): + structured_data = json.loads(structured_valid_data_json_string) expected_hex_value = "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e" - assert hash_struct(structured_valid_data_json_string).hex() == expected_hex_value + assert hash_message(structured_data).hex() == expected_hex_value def test_hash_struct_domain(structured_valid_data_json_string): + structured_data = json.loads(structured_valid_data_json_string) expected_hex_value = "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f" - assert ( - hash_struct(structured_valid_data_json_string, is_domain_separator=True).hex() == - expected_hex_value - ) + assert hash_domain(structured_data).hex() == expected_hex_value def test_hashed_structured_data(signature_kwargs): @@ -202,7 +203,7 @@ def test_structured_data_invalid_identifier_filtered_by_regex(): "tests/fixtures/invalid_struct_identifier_message.json" ).read() with pytest.raises(ValidationError) as e: - hash_struct(invalid_structured_data_string) + load_and_validate_structured_message(invalid_structured_data_string) assert str(e.value) == "Invalid Identifier `hello wallet` in `Person`" @@ -211,7 +212,7 @@ def test_structured_data_invalid_type_filtered_by_regex(): "tests/fixtures/invalid_struct_type_message.json" ).read() with pytest.raises(ValidationError) as e: - hash_struct(invalid_structured_data_string) + load_and_validate_structured_message(invalid_structured_data_string) assert str(e.value) == "Invalid Type `Hello Person` in `Mail`" @@ -220,8 +221,9 @@ def test_invalid_structured_data_value_type_mismatch_in_primary_type(): invalid_structured_data_string = open( "tests/fixtures/invalid_message_value_type_mismatch_primary_type.json" ).read() + invalid_structured_data = json.loads(invalid_structured_data_string) with pytest.raises(TypeError) as e: - hash_struct(invalid_structured_data_string) + hash_message(invalid_structured_data) assert ( str(e.value) == "Value of `contents` (12345) in the struct `Mail` is of the " "type ``, but expected string value" @@ -233,8 +235,9 @@ def test_invalid_structured_data_invalid_abi_type(): invalid_structured_data_string = open( "tests/fixtures/invalid_message_invalid_abi_type.json" ).read() + invalid_structured_data = json.loads(invalid_structured_data_string) with pytest.raises(TypeError) as e: - hash_struct(invalid_structured_data_string) + hash_message(invalid_structured_data) assert str(e.value) == "Received Invalid type `uint25689` in the struct `Person`" @@ -244,8 +247,9 @@ def test_structured_data_invalid_identifier_filtered_by_abi_encodable_function() invalid_structured_data_string = open( "tests/fixtures/invalid_message_valid_abi_type_invalid_value.json" ).read() + invalid_structured_data = json.loads(invalid_structured_data_string) with pytest.raises(TypeError) as e: - hash_struct(invalid_structured_data_string) + hash_message(invalid_structured_data) assert ( str(e.value) == "Value of `balance` (how do you do?) in the struct `Person` is of the " "type ``, but expected uint256 value" @@ -269,11 +273,15 @@ def test_unequal_array_lengths_between_schema_and_data(): invalid_structured_data_string = open( "tests/fixtures/invalid_message_unequal_1d_array_lengths.json" ).read() + invalid_structured_data = json.loads(invalid_structured_data_string) with pytest.raises(TypeError) as e: - hash_struct(invalid_structured_data_string) + hash_message(invalid_structured_data) assert ( str(e.value) == "Array data " "`[{'name': 'Bob', 'wallet': '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB'}]` has " + "dimensions `(1,)` whereas the schema has dimensions `(2,)`" or + str(e.value) == "Array data " + "`[{'wallet': '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', 'name': 'Bob'}]` has " "dimensions `(1,)` whereas the schema has dimensions `(2,)`" ) @@ -282,10 +290,14 @@ def test_unequal_array_dimension_between_schema_and_data(): invalid_structured_data_string = open( "tests/fixtures/invalid_message_unequal_array_dimensions.json" ).read() + invalid_structured_data = json.loads(invalid_structured_data_string) with pytest.raises(TypeError) as e: - hash_struct(invalid_structured_data_string) + hash_message(invalid_structured_data) assert ( str(e.value) == "Array data " "`[{'name': 'Bob', 'wallet': '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB'}]` has " + "dimensions `(1,)` whereas the schema has dimensions `(2, 3, 4)`" or + str(e.value) == "Array data " + "`[{'wallet': '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', 'name': 'Bob'}]` has " "dimensions `(1,)` whereas the schema has dimensions `(2, 3, 4)`" )