Skip to content

Commit 889ff7a

Browse files
authored
Merge pull request #12 from ajrgrubbs/v1.1.0
V1.1.0
2 parents 61d1d31 + 9bf0955 commit 889ff7a

File tree

5 files changed

+211
-4
lines changed

5 files changed

+211
-4
lines changed

eip712_structs/struct.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import functools
2+
import json
3+
import operator
14
import re
25
from collections import OrderedDict, defaultdict
36
from typing import List, Tuple, NamedTuple
47

58
from eth_utils.crypto import keccak
69

710
import eip712_structs
8-
from eip712_structs.types import Array, EIP712Type, from_solidity_type
11+
from eip712_structs.types import Array, EIP712Type, from_solidity_type, BytesJSONEncoder
912

1013

1114
class OrderedAttributesMeta(type):
@@ -180,6 +183,10 @@ def to_message(self, domain: 'EIP712Struct' = None) -> dict:
180183

181184
return result
182185

186+
def to_message_json(self, domain: 'EIP712Struct' = None) -> str:
187+
message = self.to_message(domain)
188+
return json.dumps(message, cls=BytesJSONEncoder)
189+
183190
def signable_bytes(self, domain: 'EIP712Struct' = None) -> bytes:
184191
"""Return a ``bytes`` object suitable for signing, as specified for EIP712.
185192
@@ -251,6 +258,63 @@ def from_message(cls, message_dict: dict) -> 'StructTuple':
251258

252259
return result
253260

261+
@classmethod
262+
def _assert_key_is_member(cls, key):
263+
member_names = {tup[0] for tup in cls.get_members()}
264+
if key not in member_names:
265+
raise KeyError(f'"{key}" is not defined for this struct.')
266+
267+
@classmethod
268+
def _assert_property_type(cls, key, value):
269+
"""Eagerly check for a correct member type"""
270+
members = dict(cls.get_members())
271+
typ = members[key]
272+
273+
if isinstance(typ, type) and issubclass(typ, EIP712Struct):
274+
# We expect an EIP712Struct instance. Assert that's true, and check the struct signature too.
275+
if not isinstance(value, EIP712Struct) or value._encode_type(False) != typ._encode_type(False):
276+
raise ValueError(f'Given value is of type {type(value)}, but we expected {typ}')
277+
else:
278+
# Since it isn't a nested struct, its an EIP712Type
279+
try:
280+
typ.encode_value(value)
281+
except Exception as e:
282+
raise ValueError(f'The python type {type(value)} does not appear '
283+
f'to be supported for data type {typ}.') from e
284+
285+
def __getitem__(self, key):
286+
"""Provide access directly to the underlying value dictionary"""
287+
self._assert_key_is_member(key)
288+
return self.values.__getitem__(key)
289+
290+
def __setitem__(self, key, value):
291+
"""Provide access directly to the underlying value dictionary"""
292+
self._assert_key_is_member(key)
293+
self._assert_property_type(key, value)
294+
295+
return self.values.__setitem__(key, value)
296+
297+
def __delitem__(self, _):
298+
raise TypeError('Deleting entries from an EIP712Struct is not allowed.')
299+
300+
def __eq__(self, other):
301+
if not other:
302+
# Null check
303+
return False
304+
if self is other:
305+
# Check identity
306+
return True
307+
if not isinstance(other, EIP712Struct):
308+
# Check class
309+
return False
310+
# Our structs are considered equal if their type signature and encoded value signature match.
311+
# E.g., like computing signable bytes but without a domain separator
312+
return self.encode_type() == other.encode_type() and self.encode_value() == other.encode_value()
313+
314+
def __hash__(self):
315+
value_hashes = [hash(k) ^ hash(v) for k, v in self.values.items()]
316+
return functools.reduce(operator.xor, value_hashes, hash(self.type_name))
317+
254318

255319
class StructTuple(NamedTuple):
256320
message: EIP712Struct

eip712_structs/types.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import re
2+
from json import JSONEncoder
23
from typing import Any, Union, Type
34

45
from eth_utils.crypto import keccak
5-
from eth_utils.conversions import to_int
6+
from eth_utils.conversions import to_bytes, to_hex, to_int
67

78

89
class EIP712Type:
@@ -124,6 +125,10 @@ def __init__(self, length: int = 0):
124125

125126
def _encode_value(self, value):
126127
"""Static bytesN types are encoded by right-padding to 32 bytes. Dynamic bytes types are keccak256 hashed."""
128+
if isinstance(value, str):
129+
# Try converting to a bytestring, assuming that it's been given as hex
130+
value = to_bytes(hexstr=value)
131+
127132
if self.length == 0:
128133
return keccak(value)
129134
else:
@@ -229,3 +234,11 @@ def from_solidity_type(solidity_type: str):
229234
return result
230235
else:
231236
return type_instance
237+
238+
239+
class BytesJSONEncoder(JSONEncoder):
240+
def default(self, o):
241+
if isinstance(o, bytes):
242+
return to_hex(o)
243+
else:
244+
return super(BytesJSONEncoder, self).default(o)

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88

99
NAME = 'eip712-structs'
10-
VERSION = '1.0.1'
10+
VERSION = '1.1.0'
1111

1212
install_requirements = [
1313
'eth-utils>=1.4.0',

tests/test_encode_data.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,102 @@ def test_validation_errors():
187187
bool_type.encode_value(0)
188188
with pytest.raises(ValueError, match='Must be True or False.'):
189189
bool_type.encode_value(1)
190+
191+
192+
def test_struct_eq():
193+
class Foo(EIP712Struct):
194+
s = String()
195+
foo = Foo(s='hello world')
196+
foo_copy = Foo(s='hello world')
197+
foo_2 = Foo(s='blah')
198+
199+
assert foo != None
200+
assert foo != 'unrelated type'
201+
assert foo == foo
202+
assert foo is not foo_copy
203+
assert foo == foo_copy
204+
assert foo != foo_2
205+
206+
def make_different_foo():
207+
# We want another struct defined with the same name but different member types
208+
class Foo(EIP712Struct):
209+
b = Bytes()
210+
return Foo
211+
212+
def make_same_foo():
213+
# For good measure, recreate the exact same class and ensure they can still compare
214+
class Foo(EIP712Struct):
215+
s = String()
216+
return Foo
217+
218+
OtherFooClass = make_different_foo()
219+
wrong_type = OtherFooClass(b=b'hello world')
220+
assert wrong_type != foo
221+
assert OtherFooClass != Foo
222+
223+
SameFooClass = make_same_foo()
224+
right_type = SameFooClass(s='hello world')
225+
assert right_type == foo
226+
assert SameFooClass != Foo
227+
228+
# Different name, same members
229+
class Bar(EIP712Struct):
230+
s = String()
231+
bar = Bar(s='hello world')
232+
assert bar != foo
233+
234+
235+
def test_value_access():
236+
class Foo(EIP712Struct):
237+
s = String()
238+
b = Bytes(32)
239+
240+
test_str = 'hello world'
241+
test_bytes = os.urandom(32)
242+
foo = Foo(s=test_str, b=test_bytes)
243+
244+
assert foo['s'] == test_str
245+
assert foo['b'] == test_bytes
246+
247+
test_bytes_2 = os.urandom(32)
248+
foo['b'] = test_bytes_2
249+
250+
assert foo['b'] == test_bytes_2
251+
252+
with pytest.raises(KeyError):
253+
foo['x'] = 'unacceptable'
254+
255+
# Check behavior when accessing a member that wasn't defined for the struct.
256+
with pytest.raises(KeyError):
257+
foo['x']
258+
# Lets cheat a lil bit for robustness- add an invalid 'x' member to the value dict, and check the error still raises
259+
foo.values['x'] = 'test'
260+
with pytest.raises(KeyError):
261+
foo['x']
262+
foo.values.pop('x')
263+
264+
with pytest.raises(ValueError):
265+
foo['s'] = b'unacceptable'
266+
with pytest.raises(ValueError):
267+
# Bytes do accept strings, but it has to be hex formatted.
268+
foo['b'] = 'unacceptable'
269+
270+
# Test behavior when attempting to set nested structs as values
271+
class Bar(EIP712Struct):
272+
s = String()
273+
f = Foo
274+
275+
class Baz(EIP712Struct):
276+
s = String()
277+
baz = Baz(s=test_str)
278+
279+
bar = Bar(s=test_str)
280+
bar['f'] = foo
281+
assert bar['f'] == foo
282+
283+
with pytest.raises(ValueError):
284+
# Expects a Foo type, so should throw an error
285+
bar['f'] = baz
286+
287+
with pytest.raises(TypeError):
288+
del foo['s']

tests/test_message_json.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
from eip712_structs import EIP712Struct, String, make_domain
1+
import json
2+
import os
3+
4+
import pytest
5+
6+
from eip712_structs import EIP712Struct, String, make_domain, Bytes
27

38

49
def test_flat_struct_to_message():
@@ -105,3 +110,29 @@ class Foo(EIP712Struct):
105110
assert bar_val.get_data_value('s') == 'bar'
106111

107112
assert foo.hash_struct() == new_struct.hash_struct()
113+
114+
115+
def test_bytes_json_encoder():
116+
class Foo(EIP712Struct):
117+
b = Bytes(32)
118+
domain = make_domain(name='domain')
119+
120+
bytes_val = os.urandom(32)
121+
foo = Foo(b=bytes_val)
122+
result = foo.to_message_json(domain)
123+
124+
expected_substring = f'"b": "0x{bytes_val.hex()}"'
125+
assert expected_substring in result
126+
127+
reconstructed = EIP712Struct.from_message(json.loads(result))
128+
assert reconstructed.domain == domain
129+
assert reconstructed.message == foo
130+
131+
class UnserializableObject:
132+
pass
133+
obj = UnserializableObject()
134+
135+
# Fabricate this failure case to test that the custom json encoder's fallback path works as expected.
136+
foo.values['b'] = obj
137+
with pytest.raises(TypeError, match='not JSON serializable'):
138+
foo.to_message_json(domain)

0 commit comments

Comments
 (0)