diff --git a/eip712_structs/struct.py b/eip712_structs/struct.py index 56a0c6c..b754987 100644 --- a/eip712_structs/struct.py +++ b/eip712_structs/struct.py @@ -1,3 +1,6 @@ +import functools +import json +import operator import re from collections import OrderedDict, defaultdict from typing import List, Tuple, NamedTuple @@ -5,7 +8,7 @@ from eth_utils.crypto import keccak import eip712_structs -from eip712_structs.types import Array, EIP712Type, from_solidity_type +from eip712_structs.types import Array, EIP712Type, from_solidity_type, BytesJSONEncoder class OrderedAttributesMeta(type): @@ -180,6 +183,10 @@ def to_message(self, domain: 'EIP712Struct' = None) -> dict: return result + def to_message_json(self, domain: 'EIP712Struct' = None) -> str: + message = self.to_message(domain) + return json.dumps(message, cls=BytesJSONEncoder) + def signable_bytes(self, domain: 'EIP712Struct' = None) -> bytes: """Return a ``bytes`` object suitable for signing, as specified for EIP712. @@ -251,6 +258,63 @@ def from_message(cls, message_dict: dict) -> 'StructTuple': return result + @classmethod + def _assert_key_is_member(cls, key): + member_names = {tup[0] for tup in cls.get_members()} + if key not in member_names: + raise KeyError(f'"{key}" is not defined for this struct.') + + @classmethod + def _assert_property_type(cls, key, value): + """Eagerly check for a correct member type""" + members = dict(cls.get_members()) + typ = members[key] + + if isinstance(typ, type) and issubclass(typ, EIP712Struct): + # We expect an EIP712Struct instance. Assert that's true, and check the struct signature too. + if not isinstance(value, EIP712Struct) or value._encode_type(False) != typ._encode_type(False): + raise ValueError(f'Given value is of type {type(value)}, but we expected {typ}') + else: + # Since it isn't a nested struct, its an EIP712Type + try: + typ.encode_value(value) + except Exception as e: + raise ValueError(f'The python type {type(value)} does not appear ' + f'to be supported for data type {typ}.') from e + + def __getitem__(self, key): + """Provide access directly to the underlying value dictionary""" + self._assert_key_is_member(key) + return self.values.__getitem__(key) + + def __setitem__(self, key, value): + """Provide access directly to the underlying value dictionary""" + self._assert_key_is_member(key) + self._assert_property_type(key, value) + + return self.values.__setitem__(key, value) + + def __delitem__(self, _): + raise TypeError('Deleting entries from an EIP712Struct is not allowed.') + + def __eq__(self, other): + if not other: + # Null check + return False + if self is other: + # Check identity + return True + if not isinstance(other, EIP712Struct): + # Check class + return False + # Our structs are considered equal if their type signature and encoded value signature match. + # E.g., like computing signable bytes but without a domain separator + return self.encode_type() == other.encode_type() and self.encode_value() == other.encode_value() + + def __hash__(self): + value_hashes = [hash(k) ^ hash(v) for k, v in self.values.items()] + return functools.reduce(operator.xor, value_hashes, hash(self.type_name)) + class StructTuple(NamedTuple): message: EIP712Struct diff --git a/eip712_structs/types.py b/eip712_structs/types.py index fa367de..0ab615a 100644 --- a/eip712_structs/types.py +++ b/eip712_structs/types.py @@ -1,8 +1,9 @@ import re +from json import JSONEncoder from typing import Any, Union, Type from eth_utils.crypto import keccak -from eth_utils.conversions import to_int +from eth_utils.conversions import to_bytes, to_hex, to_int class EIP712Type: @@ -124,6 +125,10 @@ def __init__(self, length: int = 0): def _encode_value(self, value): """Static bytesN types are encoded by right-padding to 32 bytes. Dynamic bytes types are keccak256 hashed.""" + if isinstance(value, str): + # Try converting to a bytestring, assuming that it's been given as hex + value = to_bytes(hexstr=value) + if self.length == 0: return keccak(value) else: @@ -229,3 +234,11 @@ def from_solidity_type(solidity_type: str): return result else: return type_instance + + +class BytesJSONEncoder(JSONEncoder): + def default(self, o): + if isinstance(o, bytes): + return to_hex(o) + else: + return super(BytesJSONEncoder, self).default(o) diff --git a/setup.py b/setup.py index 215771c..d22a369 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ NAME = 'eip712-structs' -VERSION = '1.0.1' +VERSION = '1.1.0' install_requirements = [ 'eth-utils>=1.4.0', diff --git a/tests/test_encode_data.py b/tests/test_encode_data.py index 689ab9f..943f716 100644 --- a/tests/test_encode_data.py +++ b/tests/test_encode_data.py @@ -187,3 +187,102 @@ def test_validation_errors(): bool_type.encode_value(0) with pytest.raises(ValueError, match='Must be True or False.'): bool_type.encode_value(1) + + +def test_struct_eq(): + class Foo(EIP712Struct): + s = String() + foo = Foo(s='hello world') + foo_copy = Foo(s='hello world') + foo_2 = Foo(s='blah') + + assert foo != None + assert foo != 'unrelated type' + assert foo == foo + assert foo is not foo_copy + assert foo == foo_copy + assert foo != foo_2 + + def make_different_foo(): + # We want another struct defined with the same name but different member types + class Foo(EIP712Struct): + b = Bytes() + return Foo + + def make_same_foo(): + # For good measure, recreate the exact same class and ensure they can still compare + class Foo(EIP712Struct): + s = String() + return Foo + + OtherFooClass = make_different_foo() + wrong_type = OtherFooClass(b=b'hello world') + assert wrong_type != foo + assert OtherFooClass != Foo + + SameFooClass = make_same_foo() + right_type = SameFooClass(s='hello world') + assert right_type == foo + assert SameFooClass != Foo + + # Different name, same members + class Bar(EIP712Struct): + s = String() + bar = Bar(s='hello world') + assert bar != foo + + +def test_value_access(): + class Foo(EIP712Struct): + s = String() + b = Bytes(32) + + test_str = 'hello world' + test_bytes = os.urandom(32) + foo = Foo(s=test_str, b=test_bytes) + + assert foo['s'] == test_str + assert foo['b'] == test_bytes + + test_bytes_2 = os.urandom(32) + foo['b'] = test_bytes_2 + + assert foo['b'] == test_bytes_2 + + with pytest.raises(KeyError): + foo['x'] = 'unacceptable' + + # Check behavior when accessing a member that wasn't defined for the struct. + with pytest.raises(KeyError): + foo['x'] + # Lets cheat a lil bit for robustness- add an invalid 'x' member to the value dict, and check the error still raises + foo.values['x'] = 'test' + with pytest.raises(KeyError): + foo['x'] + foo.values.pop('x') + + with pytest.raises(ValueError): + foo['s'] = b'unacceptable' + with pytest.raises(ValueError): + # Bytes do accept strings, but it has to be hex formatted. + foo['b'] = 'unacceptable' + + # Test behavior when attempting to set nested structs as values + class Bar(EIP712Struct): + s = String() + f = Foo + + class Baz(EIP712Struct): + s = String() + baz = Baz(s=test_str) + + bar = Bar(s=test_str) + bar['f'] = foo + assert bar['f'] == foo + + with pytest.raises(ValueError): + # Expects a Foo type, so should throw an error + bar['f'] = baz + + with pytest.raises(TypeError): + del foo['s'] diff --git a/tests/test_message_json.py b/tests/test_message_json.py index 050f484..d422bd5 100644 --- a/tests/test_message_json.py +++ b/tests/test_message_json.py @@ -1,4 +1,9 @@ -from eip712_structs import EIP712Struct, String, make_domain +import json +import os + +import pytest + +from eip712_structs import EIP712Struct, String, make_domain, Bytes def test_flat_struct_to_message(): @@ -105,3 +110,29 @@ class Foo(EIP712Struct): assert bar_val.get_data_value('s') == 'bar' assert foo.hash_struct() == new_struct.hash_struct() + + +def test_bytes_json_encoder(): + class Foo(EIP712Struct): + b = Bytes(32) + domain = make_domain(name='domain') + + bytes_val = os.urandom(32) + foo = Foo(b=bytes_val) + result = foo.to_message_json(domain) + + expected_substring = f'"b": "0x{bytes_val.hex()}"' + assert expected_substring in result + + reconstructed = EIP712Struct.from_message(json.loads(result)) + assert reconstructed.domain == domain + assert reconstructed.message == foo + + class UnserializableObject: + pass + obj = UnserializableObject() + + # Fabricate this failure case to test that the custom json encoder's fallback path works as expected. + foo.values['b'] = obj + with pytest.raises(TypeError, match='not JSON serializable'): + foo.to_message_json(domain)