Skip to content

Commit

Permalink
Merge pull request #12 from ajrgrubbs/v1.1.0
Browse files Browse the repository at this point in the history
V1.1.0
  • Loading branch information
ajrgrubbs authored Jun 15, 2019
2 parents 61d1d31 + 9bf0955 commit 889ff7a
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 4 deletions.
66 changes: 65 additions & 1 deletion eip712_structs/struct.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import functools
import json
import operator
import re
from collections import OrderedDict, defaultdict
from typing import List, Tuple, NamedTuple

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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion eip712_structs/types.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@


NAME = 'eip712-structs'
VERSION = '1.0.1'
VERSION = '1.1.0'

install_requirements = [
'eth-utils>=1.4.0',
Expand Down
99 changes: 99 additions & 0 deletions tests/test_encode_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
33 changes: 32 additions & 1 deletion tests/test_message_json.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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)

0 comments on commit 889ff7a

Please sign in to comment.