Skip to content

Commit d0e0e23

Browse files
committed
Add helper method to get a struct message as JSON
Structs containing any Bytes types would error out with the standard json.dumps call, since it does not support json-encoding bytes by default. Additionally, this adds __eq__ and __hash__ functions to structs - the need arose due to some comparisons I wanted to make during new tests.
1 parent 61d1d31 commit d0e0e23

File tree

4 files changed

+102
-3
lines changed

4 files changed

+102
-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
@@ -251,6 +258,24 @@ def from_message(cls, message_dict: dict) -> 'StructTuple':
251258

252259
return result
253260

261+
def __eq__(self, other):
262+
if not other:
263+
# Null check
264+
return False
265+
if self is other:
266+
# Check identity
267+
return True
268+
if not isinstance(other, EIP712Struct):
269+
# Check class
270+
return False
271+
# Our structs are considered equal if their type signature and encoded value signature match.
272+
# E.g., like computing signable bytes but without a domain separator
273+
return self.encode_type() == other.encode_type() and self.encode_value() == other.encode_value()
274+
275+
def __hash__(self):
276+
value_hashes = [hash(k) ^ hash(v) for k, v in self.values.items()]
277+
return functools.reduce(operator.xor, value_hashes, hash(self.type_name))
278+
254279

255280
class StructTuple(NamedTuple):
256281
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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,44 @@ 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 == foo
200+
assert foo is not foo_copy
201+
assert foo == foo_copy
202+
assert foo != foo_2
203+
204+
def make_different_foo():
205+
# We want another struct defined with the same name but different member types
206+
class Foo(EIP712Struct):
207+
b = Bytes()
208+
return Foo
209+
210+
def make_same_foo():
211+
# For good measure, recreate the exact same class and ensure they can still compare
212+
class Foo(EIP712Struct):
213+
s = String()
214+
return Foo
215+
216+
OtherFooClass = make_different_foo()
217+
wrong_type = OtherFooClass(b=b'hello world')
218+
assert wrong_type != foo
219+
assert OtherFooClass != Foo
220+
221+
SameFooClass = make_same_foo()
222+
right_type = SameFooClass(s='hello world')
223+
assert right_type == foo
224+
assert SameFooClass != Foo
225+
226+
# Different name, same members
227+
class Bar(EIP712Struct):
228+
s = String()
229+
bar = Bar(s='hello world')
230+
assert bar != foo

tests/test_message_json.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from eip712_structs import EIP712Struct, String, make_domain
1+
import json
2+
import os
3+
4+
from eip712_structs import EIP712Struct, String, make_domain, Bytes
25

36

47
def test_flat_struct_to_message():
@@ -105,3 +108,20 @@ class Foo(EIP712Struct):
105108
assert bar_val.get_data_value('s') == 'bar'
106109

107110
assert foo.hash_struct() == new_struct.hash_struct()
111+
112+
113+
def test_bytes_json_encoder():
114+
class Foo(EIP712Struct):
115+
b = Bytes(32)
116+
domain = make_domain(name='domain')
117+
118+
bytes_val = os.urandom(32)
119+
foo = Foo(b=bytes_val)
120+
result = foo.to_message_json(domain)
121+
122+
expected_substring = f'"b": "0x{bytes_val.hex()}"'
123+
assert expected_substring in result
124+
125+
reconstructed = EIP712Struct.from_message(json.loads(result))
126+
assert reconstructed.domain == domain
127+
assert reconstructed.message == foo

0 commit comments

Comments
 (0)