Skip to content

Commit

Permalink
feat: support nested array types (#26)
Browse files Browse the repository at this point in the history
Co-authored-by: Cedric Cordenier <[email protected]>
  • Loading branch information
cedric-cordenier and Cedric Cordenier authored Jan 6, 2023
1 parent ce84874 commit 31013ac
Show file tree
Hide file tree
Showing 2 changed files with 125 additions and 194 deletions.
246 changes: 107 additions & 139 deletions eip712/hashing.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
# copied under the MIT license from eth-account:
# https://github.com/ethereum/eth-account/blob/41f56e45b49ec1966a72cba12955bc1185cf7284/eth_account/_utils/structured_data/hashing.py

# https://github.com/ethereum/eth-account/blob/cc2feca919474203b0b23450ce7f2deed3ce985c/eth_account/_utils/structured_data/hashing.py
import json
from itertools import groupby
from operator import itemgetter

from eth_abi import encode_abi, is_encodable, is_encodable_type
from eth_abi import encode, is_encodable, is_encodable_type
from eth_abi.grammar import parse
from eth_utils import ValidationError, keccak, to_tuple, toolz
from eth_utils import keccak, to_tuple

from .validation import validate_structured_data


def get_dependencies(primary_type, types):
"""
Perform DFS to get all the dependencies of the primary_type
Perform DFS to get all the dependencies of the primary_type.
"""
deps = set()
struct_names_yet_to_be_expanded = [primary_type]
Expand All @@ -25,16 +24,23 @@ def get_dependencies(primary_type, types):
deps.add(struct_name)
fields = types[struct_name]
for field in fields:
if field["type"] not in types:
field_type = field["type"]

# Handle array types
if is_array_type(field_type):
field_type = field_type[: field_type.index("[")]

if field_type not in types:
# We don't need to expand types that are not user defined (customized)
continue
elif field["type"] in deps:
elif field_type not in deps:
# Custom Struct Type
struct_names_yet_to_be_expanded.append(field_type)
elif field_type in deps:
# skip types that we have already encountered
continue
else:
# Custom Struct Type
struct_names_yet_to_be_expanded.append(field["type"])

raise TypeError(f"Unable to determine type dependencies with type `{field_type}`.")
# Don't need to make a struct as dependency of itself
deps.remove(primary_type)

Expand All @@ -43,6 +49,7 @@ def get_dependencies(primary_type, types):

def field_identifier(field):
"""
Convert a field dict into a typed-name string.
Given a ``field`` of the format {'name': NAME, 'type': TYPE},
this function converts it to ``TYPE NAME``
"""
Expand All @@ -58,7 +65,9 @@ def encode_struct(struct_name, struct_field_types):

def encode_type(primary_type, types):
"""
The type of a struct is encoded as name ‖ "(" ‖ member₁ ‖ "," ‖ member₂ ‖ "," ‖ … ‖ memberₙ ")"
Serialize types into an encoded string.
The type of a struct is encoded as:
name ‖ "(" ‖ member₁ ‖ "," ‖ member₂ ‖ "," ‖ … ‖ memberₙ ")"
where each member is written as type ‖ " " ‖ name.
"""
# Getting the dependencies and sorting them alphabetically as per EIP712
Expand All @@ -76,15 +85,13 @@ def hash_struct_type(primary_type, types):


def is_array_type(type):
# Identify if type such as "person[]" or "person[2]" is an array
abi_type = parse(type)
return abi_type.is_array
return type.endswith("]")


@to_tuple
def get_depths_and_dimensions(data, depth):
"""
Yields 2-length tuples of depth and dimension of each element at that depth
Yields 2-length tuples of depth and dimension of each element at that depth.
"""
if not isinstance(data, (list, tuple)):
# Not checking for Iterable instance, because even Dictionaries and strings
Expand All @@ -100,151 +107,110 @@ def get_depths_and_dimensions(data, depth):

def get_array_dimensions(data):
"""
Given an array type data item, check that it is an array and
return the dimensions as a tuple.
Ex: get_array_dimensions([[1, 2, 3], [4, 5, 6]]) returns (2, 3)
Given an array type data item, check that it is an array and return the dimensions
as a tuple, in order from inside to outside.
Ex: get_array_dimensions([[1, 2, 3], [4, 5, 6]]) returns (3, 2)
"""
depths_and_dimensions = get_depths_and_dimensions(data, 0)
# re-form as a dictionary with `depth` as key, and all of the dimensions found at that depth.

# re-form as a dictionary with `depth` as key, and all of the dimensions
# found at that depth.
grouped_by_depth = {
depth: tuple(dimension for depth, dimension in group)
for depth, group in groupby(depths_and_dimensions, itemgetter(0))
}

# validate that there is only one dimension for any given depth.
invalid_depths_dimensions = tuple(
(depth, dimensions)
for depth, dimensions in grouped_by_depth.items()
if len(set(dimensions)) != 1
)
if invalid_depths_dimensions:
raise ValidationError(
"\n".join(
[
"Depth {0} of array data has more than one dimensions: {1}".format(
depth, dimensions
)
for depth, dimensions in invalid_depths_dimensions
]
)
)

dimensions = tuple(
toolz.first(set(dimensions)) for depth, dimensions in sorted(grouped_by_depth.items())
# check that all dimensions are the same, else use "dynamic"
dimensions[0] if all(dim == dimensions[0] for dim in dimensions) else "dynamic"
for _depth, dimensions in sorted(grouped_by_depth.items(), reverse=True)
)

return dimensions


@to_tuple
def flatten_multidimensional_array(array):
for item in array:
if isinstance(item, (list, tuple)):
# Not checking for Iterable instance, because even Dictionaries and strings
# are considered as iterables, but that's not what we want the condition to be.
yield from flatten_multidimensional_array(item)
else:
yield item
def encode_field(types, name, field_type, value):
if value is None:
raise ValueError(f"Missing value for field {name} of type {field_type}")

if field_type in types:
return ("bytes32", keccak(encode_data(field_type, types, value)))

@to_tuple
def _encode_data(primary_type, types, data):
# Add typehash
yield "bytes32", hash_struct_type(primary_type, types)
if field_type == "bytes":
if not isinstance(value, bytes):
raise TypeError(
f"Value of field `{name}` ({value}) is of the type `{type(value)}`, "
f"but expected bytes value"
)

# Add field contents
for field in types[primary_type]:
value = data[field["name"]]
if field["type"] == "string":
if not isinstance(value, str):
raise TypeError(
"Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected "
"string value".format(
field["name"],
primary_type,
value,
type(value),
)
)
# Special case where the values need to be keccak hashed before they are encoded
hashed_value = keccak(text=value)
yield "bytes32", hashed_value
elif field["type"] == "bytes":
if not isinstance(value, bytes):
return ("bytes32", keccak(value))

if field_type == "string":
if not isinstance(value, str):
raise TypeError(
f"Value of field `{name}` ({value}) is of the type `{type(value)}`, "
f"but expected string value"
)

return ("bytes32", keccak(text=value))

if is_array_type(field_type):
# Get the dimensions from the value
array_dimensions = get_array_dimensions(value)
# Get the dimensions from what was declared in the schema
parsed_field_type = parse(field_type)

for i in range(len(array_dimensions)):
if len(parsed_field_type.arrlist[i]) == 0:
# Skip empty or dynamically declared dimensions
continue
if array_dimensions[i] != parsed_field_type.arrlist[i][0]:
# Dimensions should match with declared schema
raise TypeError(
"Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected "
"bytes value".format(
field["name"],
primary_type,
value,
type(value),
)
f"Array data `{value}` has dimensions `{array_dimensions}`"
f" whereas the schema has dimensions "
f"`{tuple(map(lambda x: x[0] if x else 'dynamic', parsed_field_type.arrlist))}`" # noqa: E501
)
# Special case where the values need to be keccak hashed before they are encoded
hashed_value = keccak(primitive=value)
yield "bytes32", hashed_value
elif field["type"] in types:
# This means that this type is a user defined type
hashed_value = keccak(primitive=encode_data(field["type"], types, value))
yield "bytes32", hashed_value
elif is_array_type(field["type"]):
# Get the dimensions from the value
array_dimensions = get_array_dimensions(value)
# Get the dimensions from what was declared in the schema
parsed_type = parse(field["type"])
for i in range(len(array_dimensions)):
if len(parsed_type.arrlist[i]) == 0:
# Skip empty or dynamically declared dimensions
continue
if array_dimensions[i] != parsed_type.arrlist[i][0]:
# Dimensions should match with declared schema
raise TypeError(
"Array data `{0}` has dimensions `{1}` whereas the "
"schema has dimensions `{2}`".format(
value,
array_dimensions,
tuple(map(lambda x: x[0], parsed_type.arrlist)),
)
)

array_items = flatten_multidimensional_array(value)
array_items_encoding = [
encode_data(parsed_type.base, types, array_item) for array_item in array_items
]
concatenated_array_encodings = b"".join(array_items_encoding)
hashed_value = keccak(concatenated_array_encodings)
yield "bytes32", hashed_value

field_type_of_inside_array = field_type[: field_type.rindex("[")]
field_type_value_pairs = [
encode_field(types, name, field_type_of_inside_array, item) for item in value
]

# handle empty array
if value:
data_types, data_hashes = zip(*field_type_value_pairs)
else:
# First checking to see if type is valid as per abi
if not is_encodable_type(field["type"]):
raise TypeError(
"Received Invalid type `{0}` in the struct `{1}`".format(
field["type"],
primary_type,
)
)
data_types, data_hashes = [], []

return ("bytes32", keccak(encode(data_types, data_hashes)))

# First checking to see if field_type is valid as per abi
if not is_encodable_type(field_type):
raise TypeError(f"Received Invalid type `{field_type}` in field `{name}`")

# Next, see if the value is encodable as the specified field_type
if is_encodable(field_type, value):
# field_type is a valid type and the provided value is encodable as that type
return (field_type, value)
else:
raise TypeError(
f"Value of `{name}` ({value}) is not encodable as type `{field_type}`. "
f"If the base type is correct, verify that the value does not "
f"exceed the specified size for the type."
)

# Next see if the data fits the specified encoding type
if is_encodable(field["type"], value):
# field["type"] is a valid type and this value corresponds to that type.
yield field["type"], value
else:
raise TypeError(
"Value of `{0}` ({2}) in the struct `{1}` is of the type `{3}`, but expected "
"{4} value".format(
field["name"],
primary_type,
value,
type(value),
field["type"],
)
)

def encode_data(primary_type, types, data):
encoded_types = ["bytes32"]
encoded_values = [hash_struct_type(primary_type, types)]

for field in types[primary_type]:
type, value = encode_field(types, field["name"], field["type"], data[field["name"]])
encoded_types.append(type)
encoded_values.append(value)

def encode_data(primaryType, types, data):
data_types_and_hashes = _encode_data(primaryType, types, data)
data_types, data_hashes = zip(*data_types_and_hashes)
return encode_abi(data_types, data_hashes)
return encode(encoded_types, encoded_values)


def load_and_validate_structured_message(structured_json_string_data):
Expand All @@ -261,6 +227,8 @@ def hash_domain(structured_data):
def hash_message(structured_data):
return keccak(
encode_data(
structured_data["primaryType"], structured_data["types"], structured_data["message"]
structured_data["primaryType"],
structured_data["types"],
structured_data["message"],
)
)
Loading

0 comments on commit 31013ac

Please sign in to comment.