Skip to content

Commit 9bf0955

Browse files
authored
Merge pull request #10 from ajrgrubbs/json-dumps
Add helper method to get a struct message as JSON
2 parents 2ae3c3a + afaec95 commit 9bf0955

File tree

4 files changed

+115
-3
lines changed

4 files changed

+115
-3
lines changed

eip712_structs/struct.py

Lines changed: 26 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
@@ -290,6 +297,24 @@ def __setitem__(self, key, value):
290297
def __delitem__(self, _):
291298
raise TypeError('Deleting entries from an EIP712Struct is not allowed.')
292299

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+
293318

294319
class StructTuple(NamedTuple):
295320
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)

tests/test_encode_data.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,49 @@ def test_validation_errors():
189189
bool_type.encode_value(1)
190190

191191

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+
192235
def test_value_access():
193236
class Foo(EIP712Struct):
194237
s = String()

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)