Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/erc7730/lint/v2/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from erc7730.lint.v2 import ERC7730Linter, MultiLinter
from erc7730.lint.v2.lint_transaction_type_classifier import ClassifyTransactionTypeLinter
from erc7730.lint.v2.lint_validate_display_fields import ValidateDisplayFieldsLinter
from erc7730.lint.v2.lint_validate_eip712_keys import ValidateEIP712KeysLinter
from erc7730.lint.v2.lint_validate_max_length import ValidateMaxLengthLinter
from erc7730.list.list import get_erc7730_files
from erc7730.model.input.v2.descriptor import InputERC7730Descriptor
Expand Down Expand Up @@ -50,7 +51,14 @@ def lint_all(paths: list[Path], out: OutputAdder) -> int:
:param out: output adder
:return: number of files checked
"""
linter = MultiLinter([ValidateDisplayFieldsLinter(), ClassifyTransactionTypeLinter(), ValidateMaxLengthLinter()])
linter = MultiLinter(
[
ValidateDisplayFieldsLinter(),
ValidateEIP712KeysLinter(),
ClassifyTransactionTypeLinter(),
ValidateMaxLengthLinter(),
]
)

files = list(get_erc7730_files(*paths, out=out))

Expand Down
225 changes: 225 additions & 0 deletions src/erc7730/lint/v2/lint_validate_eip712_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
"""
V2 linter that validates EIP-712 display format keys are syntactically valid ``encodeType`` strings.

In v2, EIP-712 descriptors do not embed the message schema anymore: the ``display.formats`` keys *are* the schema.
Per the ERC-7730 specification, an EIP-712 format key MUST be the string returned by the ``encodeType`` function
defined in EIP-712, applied to the primary type of the message, so that the type hash computed by the wallet from
the message matches the one computed from the descriptor key::

keccak256(encodeType(typeOf(s))) == keccak256(TYPE_KEY)

Any deviation (stray whitespace, missing dependent type, wrong ordering, ...) yields a different hash, meaning the
descriptor would silently never apply. As the descriptor holds neither the domain nor the struct definitions, keys
can only be checked *syntactically* -- which is exactly what this linter does, purely locally.

Grammar validated here, from the EIP-712 specification (https://eips.ethereum.org/EIPS/eip-712)::

encodeType := structDef+
structDef := identifier "(" [ member ("," member)* ] ")"
member := type " " identifier
type := (atomicType | dynamicType | identifier) ("[" [ number ] "]")*

with the referenced struct types collected, sorted by name, and appended to the primary type encoding.
"""

import re
from typing import final, override

from erc7730.common.output import OutputAdder
from erc7730.lint.v2 import ERC7730Linter
from erc7730.model.resolved.v2.context import ResolvedEIP712Context
from erc7730.model.resolved.v2.descriptor import ResolvedERC7730Descriptor

# a valid Solidity identifier, used for member names
_IDENTIFIER = r"[A-Za-z_$][A-Za-z0-9_$]*"

# a struct type name: an identifier, optionally namespaced with colon separated segments. EIP-712 mandates a plain
# identifier, but colon namespacing is used in the wild (e.g. Hyperliquid's `HyperliquidTransaction:Withdraw`) and
# hashes fine, so it is tolerated here, consistently with `erc7730.common.abi.parse_encode_type`.
_TYPE_NAME = rf"{_IDENTIFIER}(?::{_IDENTIFIER})*"

# a type reference: a type name followed by any number of (fixed or dynamic) array suffixes
_TYPE = rf"{_TYPE_NAME}(?:\[[0-9]*\])*"

# the whole key: a concatenation of struct definitions, with nothing (not even whitespace) in between
_ENCODE_TYPE_PATTERN = re.compile(rf"(?:{_TYPE_NAME}\([^()]*\))+")

# a single struct definition, used to iterate over the key
_STRUCT_DEFINITION_PATTERN = re.compile(rf"({_TYPE_NAME})\(([^()]*)\)")

# a single struct member: exactly one space between the type and the member name
_MEMBER_PATTERN = re.compile(rf"({_TYPE}) ({_IDENTIFIER})")

# array suffixes, to strip from a type reference to get its base type
_ARRAY_SUFFIX_PATTERN = re.compile(r"(?:\[[0-9]*\])+$")

# types that look like Solidity value types but are not valid EIP-712 atomic types (aliases, out of range sizes,
# fixed point numbers), reported with a dedicated message rather than as an undefined struct type
_PRIMITIVE_LOOKALIKE_PATTERN = re.compile(r"byte|bytes[0-9]+|u?int[0-9]*|u?fixed(?:[0-9]+x[0-9]+)?")

# EIP-712 dynamic types, plus the atomic types that have no size suffix
_UNSIZED_TYPES = {"bool", "address", "string", "bytes"}


def _is_atomic_type(type_name: str) -> bool:
"""Whether a type name is an EIP-712 atomic or dynamic type.

Note EIP-712 supports neither the ``uint``/``int``/``byte`` Solidity aliases nor fixed point numbers.

:param type_name: the base type name, without array suffixes
:return: True if the type is a valid EIP-712 atomic or dynamic type
"""
if type_name in _UNSIZED_TYPES:
return True
if (match := re.fullmatch(r"bytes([1-9][0-9]?)", type_name)) is not None:
return 1 <= int(match.group(1)) <= 32
if (match := re.fullmatch(r"u?int([1-9][0-9]{0,2})", type_name)) is not None:
return 8 <= (size := int(match.group(1))) <= 256 and size % 8 == 0
return False


def _base_type(type_name: str) -> str:
"""Strip array suffixes from a type reference, e.g. ``Person[2][]`` -> ``Person``."""
return _ARRAY_SUFFIX_PATTERN.sub("", type_name)


def validate_eip712_key(key: str, out: OutputAdder) -> None:
"""Validate an EIP-712 display format key is a syntactically valid ``encodeType`` string.

Emits an error for each of:
* a key that does not match the ``encodeType`` grammar (stray whitespace, unbalanced parenthesis, ...),
* a struct or member name defined twice,
* a member type that is neither a valid EIP-712 atomic type nor a struct defined in the key,
* a struct defined in the key but never referenced,
* dependent struct types not sorted by name.

:param key: the ``display.formats`` key to validate
:param out: output adder
"""
if _ENCODE_TYPE_PATTERN.fullmatch(key) is None:
return out.error(
title="Invalid EIP-712 key",
message=f'Display format key "{key}" is not a valid EIP-712 encodeType string: it must be a '
f'concatenation of type definitions written as "TypeName(type1 name1,type2 name2)", with no '
f"whitespace or other character in between.",
)

definitions: dict[str, list[tuple[str, str]]] = {}
for definition in _STRUCT_DEFINITION_PATTERN.finditer(key):
struct_name, members_string = definition.group(1), definition.group(2)

if struct_name in definitions:
out.error(
title="Duplicate type in EIP-712 key",
message=f'Type "{struct_name}" is defined more than once in display format key "{key}". Each '
f"referenced type must be defined exactly once.",
)
continue

members: list[tuple[str, str]] = []
member_names: set[str] = set()
for member_string in members_string.split(",") if members_string else []:
if (member := _MEMBER_PATTERN.fullmatch(member_string)) is None:
out.error(
title="Invalid EIP-712 key",
message=f'Member "{member_string}" of type "{struct_name}" in display format key "{key}" is '
f'not valid: members must be written as "type name", separated by a single space, with no '
f"other whitespace.",
)
continue
member_type, member_name = member.group(1), member.group(2)
if member_name in member_names:
out.error(
title="Duplicate member in EIP-712 key",
message=f'Member "{member_name}" is defined more than once in type "{struct_name}" of display '
f'format key "{key}".',
)
continue
member_names.add(member_name)
members.append((member_type, member_name))

definitions[struct_name] = members

if not definitions:
return None

primary_type, *dependent_types = definitions

_validate_member_types(key, definitions, out)
_validate_dependent_types(key, primary_type, dependent_types, definitions, out)
return None


def _validate_member_types(key: str, definitions: dict[str, list[tuple[str, str]]], out: OutputAdder) -> None:
"""Validate all member types are either EIP-712 atomic types or struct types defined in the key."""
for struct_name, members in definitions.items():
for member_type, member_name in members:
if _is_atomic_type(base_type := _base_type(member_type)) or base_type in definitions:
continue
if _PRIMITIVE_LOOKALIKE_PATTERN.fullmatch(base_type) is not None:
out.error(
title="Invalid type in EIP-712 key",
message=f'Member "{member_name}" of type "{struct_name}" in display format key "{key}" has type '
f'"{member_type}", which is not a valid EIP-712 atomic type. Valid atomic types are bytes1 to '
f"bytes32, uint8 to uint256, int8 to int256, bool, address, string and bytes (the uint, int and "
f"byte aliases and fixed point numbers are not supported).",
)
else:
out.error(
title="Undefined type in EIP-712 key",
message=f'Member "{member_name}" of type "{struct_name}" in display format key "{key}" has type '
f'"{member_type}", which is not defined in the key. Referenced struct types must be appended to '
f"the primary type encoding.",
)


def _validate_dependent_types(
key: str,
primary_type: str,
dependent_types: list[str],
definitions: dict[str, list[tuple[str, str]]],
out: OutputAdder,
) -> None:
"""Validate dependent types are all referenced (transitively) from the primary type, and sorted by name."""
referenced: set[str] = set()
pending = [primary_type]
while pending:
if (struct_name := pending.pop()) in referenced:
continue
referenced.add(struct_name)
pending.extend(
base_type
for member_type, _ in definitions.get(struct_name, [])
if (base_type := _base_type(member_type)) in definitions
)

for struct_name in dependent_types:
if struct_name not in referenced:
out.error(
title="Unused type in EIP-712 key",
message=f'Type "{struct_name}" is defined in display format key "{key}" but is not referenced by '
f'the primary type "{primary_type}". Only referenced struct types must be appended.',
)

if dependent_types != (expected := sorted(dependent_types)):
out.error(
title="Invalid type order in EIP-712 key",
message=f'Types referenced by the primary type "{primary_type}" in display format key "{key}" must be '
f"sorted by name. Found: ({', '.join(dependent_types)}), expected: ({', '.join(expected)}).",
)


@final
class ValidateEIP712KeysLinter(ERC7730Linter):
"""
Validates that EIP-712 display format keys are syntactically valid EIP-712 ``encodeType`` strings.

Only applies to EIP-712 contexts: for contract contexts, format keys are function signatures or selectors.
"""

@override
def lint(self, descriptor: ResolvedERC7730Descriptor, out: OutputAdder) -> None:
if not isinstance(descriptor.context, ResolvedEIP712Context):
return
for key in descriptor.display.formats:
validate_eip712_key(key, out)
Empty file added tests/v2/lint/__init__.py
Empty file.
75 changes: 75 additions & 0 deletions tests/v2/lint/test_lint_validate_eip712_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import pytest

from erc7730.common.output import ListOutputAdder
from erc7730.lint.v2.lint_validate_eip712_keys import validate_eip712_key

VALID_KEYS = [
"Mail(Person from,Person to,string contents)Person(string name,address wallet)",
"PermitBatch(PermitDetails[] details,address spender,uint256 sigDeadline)"
"PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)",
"Empty()",
"Simple(uint256 value)",
"Bytes(bytes1 a,bytes32 b,bytes c,string d,bool e,address f,int8 g,int256 h)",
"Arrays(uint256[] a,uint256[2] b,uint256[2][] c)",
"Weird$Type_1(uint256 _value$1)",
# dependent types sorted by name, transitively referenced
"Root(A a)A(B b)B(uint256 c)",
# colon namespaced type names, as used by Hyperliquid in the registry
"HyperliquidTransaction:Withdraw(string hyperliquidChain,string destination,string amount,uint64 time)",
"Root(Ns:Dep d)Ns:Dep(uint256 a)",
]

INVALID_KEYS = [
# not an encodeType string at all
("TestMessage", "Invalid EIP-712 key"),
("Mail(string contents", "Invalid EIP-712 key"),
("Mail string contents)", "Invalid EIP-712 key"),
("1Mail(string contents)", "Invalid EIP-712 key"),
("", "Invalid EIP-712 key"),
# whitespace
(" Mail(string contents)", "Invalid EIP-712 key"),
("Mail(string contents) ", "Invalid EIP-712 key"),
("Mail(Person from) Person(string name)", "Invalid EIP-712 key"),
("Mail(string contents)", "Invalid EIP-712 key"),
("Mail( string contents)", "Invalid EIP-712 key"),
("Mail(string contents )", "Invalid EIP-712 key"),
("Mail(string contents, address to)", "Invalid EIP-712 key"),
("Mail(string)", "Invalid EIP-712 key"),
# malformed colon namespacing
(":Mail(string contents)", "Invalid EIP-712 key"),
("Mail:(string contents)", "Invalid EIP-712 key"),
("Ns::Mail(string contents)", "Invalid EIP-712 key"),
# duplicates
("Mail(Person a)Person(string n)Person(string n)", "Duplicate type in EIP-712 key"),
("Mail(string a,uint256 a)", "Duplicate member in EIP-712 key"),
# invalid atomic types
("Mail(uint value)", "Invalid type in EIP-712 key"),
("Mail(int value)", "Invalid type in EIP-712 key"),
("Mail(byte value)", "Invalid type in EIP-712 key"),
("Mail(uint7 value)", "Invalid type in EIP-712 key"),
("Mail(uint264 value)", "Invalid type in EIP-712 key"),
("Mail(bytes0 value)", "Invalid type in EIP-712 key"),
("Mail(bytes33 value)", "Invalid type in EIP-712 key"),
("Mail(fixed128x18 value)", "Invalid type in EIP-712 key"),
# undefined struct types
("Mail(Person from)", "Undefined type in EIP-712 key"),
("Mail(Person[] from)", "Undefined type in EIP-712 key"),
# unused struct types
("Mail(string contents)Person(string name)", "Unused type in EIP-712 key"),
# ordering
("Mail(Person from,Wallet to)Wallet(address a)Person(string n)", "Invalid type order in EIP-712 key"),
]


@pytest.mark.parametrize("key", VALID_KEYS)
def test_valid_keys(key: str) -> None:
out = ListOutputAdder()
validate_eip712_key(key, out)
assert out.outputs == []


@pytest.mark.parametrize("key,title", INVALID_KEYS, ids=[key for key, _ in INVALID_KEYS])
def test_invalid_keys(key: str, title: str) -> None:
out = ListOutputAdder()
validate_eip712_key(key, out)
assert [output.title for output in out.outputs] == [title]
Loading