Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
f458b58
logic + tests
ChristopherDedominici May 5, 2026
08805db
Create good-islands-hide.md
ChristopherDedominici May 5, 2026
9473d06
fix spelling issues
ChristopherDedominici May 5, 2026
9d6f57f
Merge branch 'EIP-712-cheatcodes-hh' of github.com:NomicFoundation/ha…
ChristopherDedominici May 5, 2026
433a986
Merge branches 'EIP-712-cheatcodes-hh' and 'main' of github.com:Nomic…
ChristopherDedominici May 7, 2026
061f5e7
add link to website docs
ChristopherDedominici May 7, 2026
a5d4090
cache the glob results
ChristopherDedominici May 7, 2026
96a94d7
fix EIP-712 collector pre-dedupe hiding same-file struct conflicts
ChristopherDedominici May 7, 2026
7a56f67
fix EIP-712 indexByName dropping encodable structs on unsupported-mem…
ChristopherDedominici May 7, 2026
692c71e
Potential fix for pull request finding
ChristopherDedominici May 7, 2026
c71f117
fix EIP-712 collector dropping transitive project files from include …
ChristopherDedominici May 8, 2026
5d7d293
Merge branch 'main' of github.com:NomicFoundation/hardhat into EIP-71…
ChristopherDedominici May 8, 2026
38068f7
Merge branch 'EIP-712-cheatcodes-hh' of github.com:NomicFoundation/ha…
ChristopherDedominici May 8, 2026
dcf0646
test EIP-712 glob matcher against npm-rooted source names
ChristopherDedominici May 12, 2026
a187b09
UDVTs resolves underlying types
ChristopherDedominici May 12, 2026
ab493ab
support `supports {a,b} and [abc]` in glob
ChristopherDedominici May 12, 2026
49ec428
Merge branch 'main' of github.com:NomicFoundation/hardhat into EIP-71…
ChristopherDedominici May 12, 2026
370a227
lint:fix
ChristopherDedominici May 12, 2026
f2cdddd
split changeset file
ChristopherDedominici May 12, 2026
4fecd9e
fix grammar errors
ChristopherDedominici May 12, 2026
c781261
fix glob bug
ChristopherDedominici May 12, 2026
c47fe34
cover empty `[]` and `[!]` char classes in glob tests
ChristopherDedominici May 12, 2026
fc6e36e
Merge branch 'EIP-712-cheatcodes-hh' of github.com:NomicFoundation/ha…
ChristopherDedominici May 12, 2026
2933352
fix spelling issues
ChristopherDedominici May 12, 2026
a17ad59
inline cross-file deps when filtering EIP-712 structs
ChristopherDedominici May 12, 2026
e043b88
throw on conflicting non-selected EIP-712 struct deps
ChristopherDedominici May 12, 2026
21822a4
move constant
ChristopherDedominici May 15, 2026
9fc53e1
move eip712Types destructure to task-action boundary
ChristopherDedominici May 15, 2026
1feac9d
simplify comment
ChristopherDedominici May 15, 2026
3e7fef7
resolve eip712Types defaults at config resolution
ChristopherDedominici May 15, 2026
f545768
return undefined for unresolvable array length
ChristopherDedominici May 15, 2026
883477f
normalize EIP-712 elementary type aliases via typeString
ChristopherDedominici May 15, 2026
27191bf
remove redundant Literal branch in array length encoding
ChristopherDedominici May 15, 2026
76523d5
remove dead interface branch in EIP-712 type encoder
ChristopherDedominici May 15, 2026
62cf8b9
skip parsing build infos that can't define EIP-712 types
ChristopherDedominici May 15, 2026
6902946
treat missing array length as dynamic in EIP-712 walker
ChristopherDedominici May 15, 2026
b100ffd
require selectedNames in EIP-712 canonicalizer
ChristopherDedominici May 15, 2026
1101e0c
simplify array-suffix stripping in EIP-712 dep walker
ChristopherDedominici May 15, 2026
cf58e3a
remove redundant canonical-string dedup in EIP-712 canonicalizer
ChristopherDedominici May 15, 2026
382df09
precompute struct deps in EIP-712 encodable loop
ChristopherDedominici May 15, 2026
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
6 changes: 6 additions & 0 deletions .changeset/clever-foxes-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
# docs: https://github.com/NomicFoundation/hardhat-website/pull/260
"@nomicfoundation/hardhat-errors": patch
---

Add error descriptor for duplicate EIP-712 struct names surfaced by the Solidity Test cheatcodes.
Comment thread
ChristopherDedominici marked this conversation as resolved.
6 changes: 6 additions & 0 deletions .changeset/good-islands-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
# docs: https://github.com/NomicFoundation/hardhat-website/pull/260
"hardhat": patch
---

Support EIP-712 cheatcodes in Solidity Test.
10 changes: 10 additions & 0 deletions packages/hardhat-errors/src/descriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,16 @@ Double-check the paths you are providing to the \`test solidity\` task.`,
websiteDescription:
"Two distinct snapshot group names sanitize to the same on-disk filename. Rename one of the groups in your Solidity tests so they produce different filenames.",
},
EIP712_DUPLICATE_STRUCT_NAME: {
number: 818,
messageTemplate: `Two different EIP-712 struct definitions named "{name}" were found:
- {firstSource}
- {secondSource}

EIP-712 cheatcodes resolve types by name, so each struct name must have a single canonical definition. Rename one of the structs, or scope your \`test.solidity.eip712Types.include\` / \`exclude\` globs in \`hardhat.config.ts\` to only one of them.`,
websiteTitle: "Duplicate EIP-712 struct name",
websiteDescription: `Two struct definitions with the same name had different members. Type-name lookups via \`vm.eip712HashType\` and \`vm.eip712HashStruct\` would be ambiguous.`,
},
},
SOLIDITY: {
PROJECT_ROOT_RESOLUTION_ERROR: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ const solidityTestUserConfigType = z.object({
shrinkRunLimit: z.number().optional(),
})
.optional(),
eip712Types: z
.object({
include: z.array(z.string()).optional(),
exclude: z.array(z.string()).optional(),
})
.optional(),
});

const userConfigType = z.object({
Expand Down Expand Up @@ -169,6 +175,9 @@ export async function resolveSolidityTestUserConfig(
...userConfig.test?.solidity,
fuzz: resolveFuzzConfig(userConfig.test?.solidity?.fuzz),
forking: resolvedForking,
eip712Types: resolveEip712TypesConfig(
userConfig.test?.solidity?.eip712Types,
),
};

return {
Expand All @@ -195,3 +204,12 @@ export function resolveFuzzConfig(
seed: fuzzUserConfig.seed ?? DEFAULT_FUZZ_SEED,
};
}

export function resolveEip712TypesConfig(
eip712TypesUserConfig: SolidityTestUserConfig["eip712Types"] = {},
): SolidityTestConfig["eip712Types"] {
return {
include: eip712TypesUserConfig.include ?? [],
exclude: eip712TypesUserConfig.exclude ?? [],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
import { isObject } from "@nomicfoundation/hardhat-utils/lang";

/**
* A single field of a Solidity struct
*/
export interface StructMember {
name: string;
/**
* EIP-712 type string, or `undefined` if the type can't be encoded (e.g. mappings).
*/
type: string | undefined;
}

/**
* A Solidity struct extracted from a source AST (Abstract Syntax Tree).
*/
export interface CollectedStruct {
name: string;
members: StructMember[];
/**
* Project-relative source path. Used for diagnostics only.
*/
sourcePath: string;
}

/**
* A user-defined value type (`type Foo is bytes32;`) resolves to its
* underlying elementary type for EIP-712 encoding.
* The map is keyed by the user-defined value type definition's solc node id,
* which is what `referencedDeclaration` on a `UserDefinedTypeName` reference
* points to.
*/
export type UserDefinedValueTypeIndex = Map<number, Record<string, unknown>>;

/**
* Walks every AST in the build and indexes every `UserDefinedValueTypeDefinition`
* by its node `id`, mapping to its `underlyingType` (an `ElementaryTypeName`).
*
* User-defined value types can sit at file scope or inside a contract, and a
* struct in one source may reference a user-defined value type defined in
* another, so this must run across every AST in the build — not just the ones
* matched by the user's include globs.
*/
export function buildUserDefinedValueTypeIndex(
asts: unknown[],
): UserDefinedValueTypeIndex {
const index: UserDefinedValueTypeIndex = new Map();

for (const ast of asts) {
if (!isObject(ast) || ast.nodeType !== "SourceUnit") {
continue;
}

const topLevelNodes: unknown[] = Array.isArray(ast.nodes) ? ast.nodes : [];
for (const node of topLevelNodes) {
if (!isObject(node)) {
continue;
}

if (node.nodeType === "UserDefinedValueTypeDefinition") {
recordUserDefinedValueType(node, index);
} else if (node.nodeType === "ContractDefinition") {
const members: unknown[] = Array.isArray(node.nodes) ? node.nodes : [];
for (const member of members) {
if (
isObject(member) &&
member.nodeType === "UserDefinedValueTypeDefinition"
) {
recordUserDefinedValueType(member, index);
}
}
}
}
}

return index;
}

function recordUserDefinedValueType(
node: Record<string, unknown>,
index: UserDefinedValueTypeIndex,
): void {
if (
typeof node.id === "number" &&
isObject(node.underlyingType) &&
node.underlyingType.nodeType === "ElementaryTypeName"
) {
index.set(node.id, node.underlyingType);
}
}

/**
* Returns every struct definition reachable from a solc source AST (Abstract Syntax Tree),
* including structs nested inside contracts.
*/
export function extractStructsFromAst(
ast: unknown,
sourcePath: string,
userDefinedValueTypeI: UserDefinedValueTypeIndex = new Map(),
): CollectedStruct[] {
if (!isObject(ast) || ast.nodeType !== "SourceUnit") {
return [];
}

const results: CollectedStruct[] = [];
const topLevelNodes: unknown[] = Array.isArray(ast.nodes) ? ast.nodes : [];

for (const node of topLevelNodes) {
if (!isObject(node)) {
continue;
}

if (node.nodeType === "StructDefinition") {
const collected = collectStruct(node, sourcePath, userDefinedValueTypeI);
if (collected !== undefined) {
results.push(collected);
}
} else if (node.nodeType === "ContractDefinition") {
const members: unknown[] = Array.isArray(node.nodes) ? node.nodes : [];
for (const member of members) {
if (isObject(member) && member.nodeType === "StructDefinition") {
const collected = collectStruct(
member,
sourcePath,
userDefinedValueTypeI,
);
if (collected !== undefined) {
results.push(collected);
}
}
}
}
}

return results;
}

function collectStruct(
node: Record<string, unknown>,
sourcePath: string,
userDefinedValueTypeI: UserDefinedValueTypeIndex,
): CollectedStruct | undefined {
if (typeof node.name !== "string") {
return undefined;
}

const memberNodes: unknown[] = Array.isArray(node.members)
? node.members
: [];
const members: StructMember[] = [];

for (const memberNode of memberNodes) {
if (
!isObject(memberNode) ||
memberNode.nodeType !== "VariableDeclaration"
) {
continue;
}

if (typeof memberNode.name !== "string") {
continue;
}

members.push({
name: memberNode.name,
type: encodeMemberType(memberNode.typeName, userDefinedValueTypeI),
});
}

return {
name: node.name,
members,
sourcePath,
};
}

/**
* Converts a solc `typeName` AST node into its EIP-712 type string, following
* the same conventions as `forge bind-json`:
*
* - elementary types pass through (`address`, `uint256`, `string`, ...)
* - enums → `uint8`
* - contracts / interfaces → `address`
* - structs → bare name (`Wallet.Person` → `Person`)
* - user-defined value types → underlying elementary type (`type Foo is bytes32` → `bytes32`)
* - arrays → `T[]` (dynamic) or `T[N]` (fixed)
* - mappings / functions → `undefined` (not EIP-712 encodable)
*/
export function encodeMemberType(
typeName: unknown,
userDefinedValueTypeI: UserDefinedValueTypeIndex = new Map(),
): string | undefined {
if (!isObject(typeName)) {
return undefined;
}

switch (typeName.nodeType) {
case "ElementaryTypeName": {
// Prefer `typeDescriptions.typeString` over `name`: solc emits the
// unresolved alias in `name` (`uint`, `int`, `byte`), but the canonical
// EIP-712 type is in `typeString` (`uint256`, `int256`, `bytes1`).
// `address payable` is the exception — `typeString` is `"address payable"`
// while the canonical EIP-712 type is just `address`.
const desc = isObject(typeName.typeDescriptions)
? typeName.typeDescriptions
: undefined;
const typeString =
typeof desc?.typeString === "string" ? desc.typeString : undefined;
if (typeString !== undefined) {
return typeString.endsWith(" payable")
? typeString.slice(0, -" payable".length)
: typeString;
}

return typeof typeName.name === "string" ? typeName.name : undefined;
}
Comment thread
schaable marked this conversation as resolved.

case "UserDefinedTypeName": {
// `typeDescriptions.typeString` is the only reliable way to tell what
// kind of user-defined type this is — e.g. "struct Foo", "enum Bar",
// "contract Token". The AST node itself doesn't say. Note that solc
// emits the "contract " prefix for interface references as well.
const desc = isObject(typeName.typeDescriptions)
? typeName.typeDescriptions
: undefined;
const typeString =
typeof desc?.typeString === "string" ? desc.typeString : "";

if (typeString.startsWith("enum ")) {
return "uint8";
}

if (typeString.startsWith("contract ")) {
return "address";
}

if (typeString.startsWith("struct ")) {
// EIP-712 references structs by their bare name, so strip both the
// "struct " prefix, any storage-location suffix solc may append
// ("memory", "storage", ...), and any qualifier ("Wallet.Person").
const remainder = typeString.slice("struct ".length).trim();
const namePart = remainder.split(/\s+/)[0];
const segments = namePart.split(".");
return segments[segments.length - 1];
}

// User-defined value types (`type Foo is bytes32;`, solc 0.8.8+).
// Resolve via `referencedDeclaration` against the build-wide
// user-defined value type index
// and recurse on the underlying elementary type — matching forge's
// `Resolver::resolve_type` so `Foo h` encodes as `bytes32 h`, not `Foo h`.
const refId =
typeof typeName.referencedDeclaration === "number"
? typeName.referencedDeclaration
: isObject(typeName.pathNode) &&
typeof typeName.pathNode.referencedDeclaration === "number"
? typeName.pathNode.referencedDeclaration
: undefined;

if (refId !== undefined) {
const underlying = userDefinedValueTypeI.get(refId);
if (underlying !== undefined) {
return encodeMemberType(underlying, userDefinedValueTypeI);
}
}

// Fallback when the reference can't be resolved (missing
// `referencedDeclaration`, or its definition wasn't in the build).
// Emitting the name lets the downstream encoder produce a clear error
// rather than failing silently here.
if (typeof typeName.name === "string") {
return typeName.name;
}

if (
isObject(typeName.pathNode) &&
typeof typeName.pathNode.name === "string"
) {
const segments = typeName.pathNode.name.split(".");
return segments[segments.length - 1];
}

return undefined;
}

case "ArrayTypeName": {
const base = encodeMemberType(typeName.baseType, userDefinedValueTypeI);
if (base === undefined) {
return undefined;
}

// solc omits `length` entirely for dynamic arrays; it isn't emitted as `null`.
const length = typeName.length;
if (length === null || length === undefined) {
return `${base}[]`;
}

// Always read from typeString: `Literal.value` preserves source text
// (`0x100`, `1_000`) but typeString canonicalizes to decimal.
const desc = isObject(typeName.typeDescriptions)
? typeName.typeDescriptions
: undefined;
const typeString =
typeof desc?.typeString === "string" ? desc.typeString : "";
const match = /\[(\d+)\]$/.exec(typeString);

if (match !== null) {
return `${base}[${match[1]}]`;
}

return undefined;
}

case "Mapping":
return undefined;

case "FunctionTypeName":
// EIP-712 can't encode function types.
return undefined;

default:
return undefined;
}
}
Loading
Loading