From f458b580b8a2bbd5203fbecd32f0e79f742b2294 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Tue, 5 May 2026 09:56:11 +0000 Subject: [PATCH 01/34] logic + tests --- packages/hardhat-errors/src/descriptors.ts | 10 + .../builtin-plugins/solidity-test/config.ts | 6 + .../solidity-test/eip712/ast-walker.ts | 221 ++++++++++ .../solidity-test/eip712/canonicalize.ts | 219 ++++++++++ .../solidity-test/eip712/glob.ts | 75 ++++ .../solidity-test/eip712/index.ts | 93 ++++ .../builtin-plugins/solidity-test/helpers.ts | 8 +- .../solidity-test/task-action.ts | 7 + .../solidity-test/type-extensions.ts | 5 + .../solidity-test/eip712/ast-walker.ts | 402 ++++++++++++++++++ .../solidity-test/eip712/canonicalize.ts | 227 ++++++++++ .../solidity-test/eip712/glob.ts | 105 +++++ .../solidity-test/eip712/index.ts | 357 ++++++++++++++++ 13 files changed, 1734 insertions(+), 1 deletion(-) create mode 100644 packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts create mode 100644 packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts create mode 100644 packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts create mode 100644 packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts create mode 100644 packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts create mode 100644 packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts create mode 100644 packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts create mode 100644 packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts diff --git a/packages/hardhat-errors/src/descriptors.ts b/packages/hardhat-errors/src/descriptors.ts index c762310da1e..84cbdf0040f 100644 --- a/packages/hardhat-errors/src/descriptors.ts +++ b/packages/hardhat-errors/src/descriptors.ts @@ -1221,6 +1221,16 @@ Double-check the paths you are providing to the \`test solidity\` task.`, websiteTitle: "Selected Solidity test files do not exist", websiteDescription: `You ran the \`test solidity\` task with files that do not exist on disk.`, }, + EIP712_DUPLICATE_STRUCT_NAME: { + number: 817, + 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 produced different EIP-712 type strings. Type-name lookups via \`vm.eip712HashType\` and \`vm.eip712HashStruct\` would be ambiguous.`, + }, }, SOLIDITY: { PROJECT_ROOT_RESOLUTION_ERROR: { diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/config.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/config.ts index da75c2eaa83..519a312409d 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/config.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/config.ts @@ -88,6 +88,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({ diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts new file mode 100644 index 00000000000..fbfcbeea69d --- /dev/null +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -0,0 +1,221 @@ +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; +} + +/** + * 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, +): 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); + 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); + if (collected !== undefined) { + results.push(collected); + } + } + } + } + } + + return results; +} + +function collectStruct( + node: Record, + sourcePath: string, +): 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), + }); + } + + 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`) + * - arrays → `T[]` (dynamic) or `T[N]` (fixed) + * - mappings / functions → `undefined` (not EIP-712 encodable) + */ +export function encodeMemberType(typeName: unknown): string | undefined { + if (!isObject(typeName)) { + return undefined; + } + + switch (typeName.nodeType) { + case "ElementaryTypeName": { + return typeof typeName.name === "string" ? typeName.name : undefined; + } + + 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", "interface IFoo". The AST node itself doesn't say. + 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 ") || + typeString.startsWith("interface ") + ) { + 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]; + } + + // Fallback for user-defined value types (solc 0.8.8+) and type aliases. + // Some of these aren't EIP-712 encodable; emitting the name lets the + // downstream encoder produce a clear error rather than failing 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); + if (base === undefined) { + return undefined; + } + + const length = typeName.length; + if (length === null || length === undefined) { + return `${base}[]`; + } + + if ( + isObject(length) && + length.nodeType === "Literal" && + typeof length.value === "string" + ) { + return `${base}[${length.value}]`; + } + + // The length wasn't a plain literal (e.g. `uint[CONST]`). solc still + // records the resolved size in the array's `typeString`, so parse it + // from there. + 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 `${base}[]`; + } + + case "Mapping": + return undefined; + + case "FunctionTypeName": + // EIP-712 can't encode function types. + return undefined; + + default: + return undefined; + } +} diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts new file mode 100644 index 00000000000..09983efc301 --- /dev/null +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -0,0 +1,219 @@ +import type { CollectedStruct } from "./ast-walker.js"; + +import { HardhatError } from "@nomicfoundation/hardhat-errors"; + +/** + * Produces the flat list of canonical EIP-712 type strings expected by EDR. + * + * Each encodable struct contributes one entry, built like this: + * 1. Start with the struct's own head: `Name(type1 name1,type2 name2,...)`. + * 2. If the struct has fields that reference other structs, append those + * structs' heads after it, sorted alphabetically. + * + * Examples: + * - `Person` has only primitive fields (address, string), so its entry is + * just its own head: + * `Person(address wallet,string name)` + * - `Mail` has a `Person` field, so its entry is its head plus `Person`'s + * head appended: + * `Mail(Person from,Person to,string contents)Person(address wallet,string name)` + * + * Structs that contain members whose type cannot be EIP-712 encoded (mappings, + * function types, etc.) are dropped entirely, along with any structs that + * depend on them transitively. This matches `forge bind-json`'s behavior: + * `resolve_struct_eip712` returns `None` for any struct containing unsupported + * constructs and propagates `None` through the dep graph so dependents are + * also dropped. + */ +export function canonicalizeStructs(structs: CollectedStruct[]): string[] { + const byName = indexByName(structs); + const knownNames = new Set(byName.keys()); + const encodable = computeEncodable(byName, knownNames); + const seen = new Set(); + const result: string[] = []; + + for (const struct of byName.values()) { + if (!encodable.has(struct.name)) { + continue; + } + + const head = encodeStructHead(struct); + const deps = transitiveDeps(struct, byName) + .map((depName) => { + const def = byName.get(depName); + return def === undefined ? undefined : encodeStructHead(def); + }) + .filter((s): s is string => s !== undefined); + + deps.sort(); + + const canonical = head + deps.join(""); + if (!seen.has(canonical)) { + seen.add(canonical); + result.push(canonical); + } + } + + return result; +} + +/** + * Returns the set of struct names that are EIP-712 encodable. A struct is + * encodable if none of its members has an non-decodable type (`type === undefined`, + * e.g. mappings or function types) AND every one of its struct deps — direct or + * transitive — is itself encodable. + */ +function computeEncodable( + byName: Map, + knownNames: Set, +): Set { + const encodable = new Set(byName.keys()); + + for (const [name, struct] of byName) { + if (struct.members.some((m) => m.type === undefined)) { + encodable.delete(name); + } + } + + let changed = true; + while (changed) { + changed = false; + for (const name of [...encodable]) { + const struct = byName.get(name); + if (struct === undefined) { + continue; + } + + const deps = directStructDeps(struct, knownNames); + if (deps.some((dep) => !encodable.has(dep))) { + encodable.delete(name); + changed = true; + } + } + } + + return encodable; +} + +/** + * Computes the EIP-712 `encodeType` string for one struct in isolation: + * `Name(type1 name1,type2 name2,...)`. + * Members whose type is `undefined` (e.g. mappings) are dropped. + */ +function encodeStructHead(struct: CollectedStruct): string { + const memberSegments: string[] = []; + for (const member of struct.members) { + if (member.type === undefined) { + continue; + } + + memberSegments.push(`${member.type} ${member.name}`); + } + + return `${struct.name}(${memberSegments.join(",")})`; +} + +/** + * Returns the names of all struct dependencies referenced by `struct`'s + * members. Considers the base type of arrays. Members whose base type does not + * resolve to a known struct (elementary types, address, etc.) are ignored. + */ +function directStructDeps( + struct: CollectedStruct, + knownStructNames: Set, +): string[] { + const deps = new Set(); + for (const member of struct.members) { + if (member.type === undefined) { + continue; + } + + // Strip array suffixes: `Foo[]`, `Foo[3]`, `Foo[3][2]` → `Foo`. + let base = member.type; + while (true) { + const match = /^(.*)\[[^\]]*\]$/.exec(base); + + if (match === null) { + break; + } + + base = match[1]; + } + + if (knownStructNames.has(base) && base !== struct.name) { + deps.add(base); + } + } + + return [...deps]; +} + +/** + * Walks the dep graph from `root` and returns the set of all transitively + * referenced struct names (excluding the root itself). + */ +function transitiveDeps( + root: CollectedStruct, + byName: Map, +): string[] { + const visited = new Set(); + const knownNames = new Set(byName.keys()); + const stack = directStructDeps(root, knownNames); + + while (stack.length > 0) { + const next = stack.pop(); + + if (next === undefined || visited.has(next) || next === root.name) { + continue; + } + + visited.add(next); + + const def = byName.get(next); + + if (def !== undefined) { + stack.push(...directStructDeps(def, knownNames)); + } + } + + return [...visited]; +} + +/** + * Builds a name → definition map from collected structs, throwing + * `EIP712_DUPLICATE_STRUCT_NAME` when two structs share a name but produce + * different `encodeType` heads. Identical heads are silently deduplicated + * (same struct seen across multiple build infos / partial recompiles). + */ +function indexByName(structs: CollectedStruct[]): Map { + const byName = new Map(); + const headByName = new Map(); + const sourceByName = new Map(); + + for (const struct of structs) { + const head = encodeStructHead(struct); + const existingHead = headByName.get(struct.name); + + if (existingHead === undefined) { + byName.set(struct.name, struct); + headByName.set(struct.name, head); + sourceByName.set(struct.name, struct.sourcePath); + continue; + } + + if (existingHead === head) { + continue; + } + + throw new HardhatError( + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, + { + name: struct.name, + firstSource: sourceByName.get(struct.name) ?? "", + secondSource: struct.sourcePath, + }, + ); + } + + return byName; +} diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts new file mode 100644 index 00000000000..0270359f2c7 --- /dev/null +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts @@ -0,0 +1,75 @@ +/** + * Returns true when `path` should be included given user-supplied include and + * exclude glob lists. `include` is the gate: an empty `include` matches + * nothing. `exclude` then narrows the included set. + */ +export function isPathSelected( + path: string, + include: string[], + exclude: string[], +): boolean { + if (include.length === 0) { + return false; + } + + if (!matchesAny(path, include)) { + return false; + } + + if (exclude.length > 0 && matchesAny(path, exclude)) { + return false; + } + + return true; +} + +/** + * Returns true if `value` matches at least one of the given glob patterns. + */ +function matchesAny(value: string, patterns: string[]): boolean { + for (const pattern of patterns) { + if (globToRegExp(pattern).test(value)) { + return true; + } + } + + return false; +} + +/** + * Compiles a glob pattern into a regular expression. Supports: + * - `*` : zero or more non-slash characters + * - `**` : zero or more characters (including slashes) + * - `?` : exactly one non-slash character + * All other regex metacharacters are escaped. Slashes are matched literally. + */ +function globToRegExp(pattern: string): RegExp { + let regex = "^"; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + + if (c === "*") { + if (pattern[i + 1] === "*") { + regex += ".*"; + i += 2; + } else { + regex += "[^/]*"; + i += 1; + } + } else if (c === "?") { + regex += "[^/]"; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + regex += `\\${c}`; + i += 1; + } else { + regex += c; + i += 1; + } + } + + regex += "$"; + + return new RegExp(regex); +} diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts new file mode 100644 index 00000000000..f6464459661 --- /dev/null +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -0,0 +1,93 @@ +import type { CollectedStruct } from "./ast-walker.js"; +import type { + SolidityBuildInfo, + SolidityBuildInfoOutput, +} from "../../../../types/solidity/solidity-artifacts.js"; +import type { BuildInfoAndOutput } from "../edr-artifacts.js"; + +import { bytesToUtf8String } from "@nomicfoundation/hardhat-utils/bytes"; + +import { extractStructsFromAst } from "./ast-walker.js"; +import { canonicalizeStructs } from "./canonicalize.js"; +import { isPathSelected } from "./glob.js"; + +export interface Eip712TypesConfig { + include?: string[]; + exclude?: string[]; +} + +/** + * Walks every compiled source's AST whose user source name matches the + * configured `include` globs (and isn't matched by `exclude`), extracts + * every struct definition, and returns the flat list of canonical EIP-712 + * type strings expected by EDR's `eip712CanonicalTypes` config field. + * + * When `include` is empty/unset the feature is off: collection short-circuits + * and returns an empty list without parsing any build info. + */ +export function collectEip712CanonicalTypes( + buildInfosAndOutputs: BuildInfoAndOutput[], + config: Eip712TypesConfig | undefined, +): string[] { + const include = config?.include ?? []; + const exclude = config?.exclude ?? []; + + if (include.length === 0) { + return []; + } + + const parsed = buildInfosAndOutputs.map(({ buildInfo, output }) => { + const parsedBuildInfo: SolidityBuildInfo = JSON.parse( + bytesToUtf8String(buildInfo), + ); + const parsedOutput: SolidityBuildInfoOutput = JSON.parse( + bytesToUtf8String(output), + ); + + return { buildInfo: parsedBuildInfo, output: parsedOutput }; + }); + + const inputToUserSource = new Map(); + for (const { buildInfo } of parsed) { + for (const [userSource, inputSource] of Object.entries( + buildInfo.userSourceNameMap, + )) { + inputToUserSource.set(inputSource, userSource); + } + } + + // The same source can be compiled into more than one build info, so + // dedupe structs by (sourceName, structName) before collecting them. + const seen = new Set(); + const collected: CollectedStruct[] = []; + + for (const { output } of parsed) { + const sources = output.output.sources; + if (sources === undefined) { + continue; + } + + for (const [inputSourceName, source] of Object.entries(sources)) { + const userSourceName = + inputToUserSource.get(inputSourceName) ?? inputSourceName; + + if (!isPathSelected(userSourceName, include, exclude)) { + continue; + } + + const structs = extractStructsFromAst(source.ast, userSourceName); + for (const struct of structs) { + const key = `${userSourceName}::${struct.name}`; + + if (seen.has(key)) { + continue; + } + + seen.add(key); + collected.push(struct); + } + } + } + + return canonicalizeStructs(collected); +} diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/helpers.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/helpers.ts index 43c71a2b5e8..2591e5eee1d 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/helpers.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/helpers.ts @@ -38,6 +38,7 @@ interface SolidityTestConfigParams { testPattern?: string; generateGasReport: boolean; testFunctionOverrides?: TestFunctionOverride[]; + eip712CanonicalTypes?: string[]; } export async function solidityTestConfigToSolidityTestRunnerConfigArgs({ @@ -50,6 +51,7 @@ export async function solidityTestConfigToSolidityTestRunnerConfigArgs({ testPattern, generateGasReport, testFunctionOverrides, + eip712CanonicalTypes, }: SolidityTestConfigParams): Promise { const fsPermissions: PathPermission[] | undefined = [ config.fsPermissions?.readWriteFile?.map((p) => ({ @@ -122,10 +124,13 @@ export async function solidityTestConfigToSolidityTestRunnerConfigArgs({ const shouldAlwaysCollectStackTraces = verbosity > DEFAULT_VERBOSITY; + // `eip712Types` must be passed as a separate param, not via `config` + const { eip712Types: _, ...configWithoutEip712 } = config; + return { projectRoot, hardfork: resolvedHardfork, - ...config, + ...configWithoutEip712, fsPermissions, localPredeploys, sender, @@ -145,6 +150,7 @@ export async function solidityTestConfigToSolidityTestRunnerConfigArgs({ ? CollectStackTraces.Always : CollectStackTraces.OnFailure, testFunctionOverrides, + eip712CanonicalTypes, }; } diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts index cecee7f548a..1238e393f2c 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts @@ -35,6 +35,7 @@ import { buildEdrArtifactsWithMetadata, getBuildInfosAndOutputs, } from "./edr-artifacts.js"; +import { collectEip712CanonicalTypes } from "./eip712/index.js"; import { isTestSuiteArtifact, warnDeprecatedTestFail, @@ -229,6 +230,11 @@ const runSolidityTests: NewTaskActionFunction = async ( allBuildInfosAndOutputs, ); + const eip712CanonicalTypes = collectEip712CanonicalTypes( + allBuildInfosAndOutputs, + solidityTestConfig.eip712Types, + ); + const testRunnerConfig = await solidityTestConfigToSolidityTestRunnerConfigArgs({ chainType, @@ -242,6 +248,7 @@ const runSolidityTests: NewTaskActionFunction = async ( hre.globalOptions.gasStats || hre.globalOptions.gasStatsJson !== undefined, testFunctionOverrides, + eip712CanonicalTypes, }); const tracingConfig: TracingConfigWithBuffers = { buildInfos: allBuildInfosAndOutputs.map(({ buildInfo, output }) => ({ diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/type-extensions.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/type-extensions.ts index 83d7fd9c66a..11ebf18ce77 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/type-extensions.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/type-extensions.ts @@ -64,6 +64,11 @@ declare module "../../../types/test.js" { includePushBytes?: boolean; shrinkRunLimit?: number; }; + + eip712Types?: { + include?: string[]; + exclude?: string[]; + }; } export interface SolidityTestForkingUserConfig { diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts new file mode 100644 index 00000000000..fb23be029ca --- /dev/null +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -0,0 +1,402 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + encodeMemberType, + extractStructsFromAst, +} from "../../../../../src/internal/builtin-plugins/solidity-test/eip712/ast-walker.js"; + +describe("eip712 - ast-walker", () => { + describe("extractStructsFromAst", () => { + describe("entry guard", () => { + it("returns empty for non-SourceUnit AST", () => { + assert.deepEqual( + extractStructsFromAst({ nodeType: "Other" }, "a.sol"), + [], + ); + }); + }); + + describe("StructDefinition (top-level)", () => { + it("collects file-level struct definitions", () => { + const ast = { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "StructDefinition", + name: "Person", + members: [ + { + nodeType: "VariableDeclaration", + name: "wallet", + typeName: { nodeType: "ElementaryTypeName", name: "address" }, + }, + { + nodeType: "VariableDeclaration", + name: "name", + typeName: { nodeType: "ElementaryTypeName", name: "string" }, + }, + ], + }, + ], + }; + + const out = extractStructsFromAst(ast, "test/Types.sol"); + + assert.deepEqual(out, [ + { + name: "Person", + sourcePath: "test/Types.sol", + members: [ + { name: "wallet", type: "address" }, + { name: "name", type: "string" }, + ], + }, + ]); + }); + }); + + describe("ContractDefinition (nested structs)", () => { + it("collects structs nested inside contracts", () => { + const ast = { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "ContractDefinition", + name: "Wallet", + nodes: [ + { + nodeType: "StructDefinition", + name: "Owner", + members: [ + { + nodeType: "VariableDeclaration", + name: "addr", + typeName: { + nodeType: "ElementaryTypeName", + name: "address", + }, + }, + ], + }, + ], + }, + ], + }; + + const out = extractStructsFromAst(ast, "Wallet.sol"); + + assert.deepEqual(out, [ + { + name: "Owner", + sourcePath: "Wallet.sol", + members: [{ name: "addr", type: "address" }], + }, + ]); + }); + }); + + describe("combined", () => { + it("collects multiple structs from one file (file-level and contract-nested)", () => { + const ast = { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "StructDefinition", + name: "Outer", + members: [ + { + nodeType: "VariableDeclaration", + name: "x", + typeName: { + nodeType: "ElementaryTypeName", + name: "uint256", + }, + }, + ], + }, + { + nodeType: "ContractDefinition", + name: "Wallet", + nodes: [ + { + nodeType: "StructDefinition", + name: "Inner", + members: [ + { + nodeType: "VariableDeclaration", + name: "y", + typeName: { + nodeType: "ElementaryTypeName", + name: "bool", + }, + }, + ], + }, + ], + }, + ], + }; + + const out = extractStructsFromAst(ast, "Mixed.sol"); + + assert.deepEqual(out, [ + { + name: "Outer", + sourcePath: "Mixed.sol", + members: [{ name: "x", type: "uint256" }], + }, + { + name: "Inner", + sourcePath: "Mixed.sol", + members: [{ name: "y", type: "bool" }], + }, + ]); + }); + }); + + describe("collectStruct (member collection)", () => { + it("drops members with no name", () => { + const ast = { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "StructDefinition", + name: "Anon", + members: [ + { + nodeType: "VariableDeclaration", + typeName: { nodeType: "ElementaryTypeName", name: "uint256" }, + }, + { + nodeType: "VariableDeclaration", + name: "x", + typeName: { nodeType: "ElementaryTypeName", name: "uint256" }, + }, + ], + }, + ], + }; + + const out = extractStructsFromAst(ast, "Anon.sol"); + + assert.deepEqual(out, [ + { + name: "Anon", + sourcePath: "Anon.sol", + members: [{ name: "x", type: "uint256" }], + }, + ]); + }); + }); + }); + + describe("encodeMemberType", () => { + describe("entry guard", () => { + it("returns undefined for non-object input", () => { + assert.equal(encodeMemberType(null), undefined); + assert.equal(encodeMemberType(undefined), undefined); + assert.equal(encodeMemberType("uint256"), undefined); + assert.equal(encodeMemberType(42), undefined); + assert.equal(encodeMemberType([]), undefined); + }); + }); + + describe("ElementaryTypeName", () => { + it("encodes elementary types verbatim", () => { + assert.equal( + encodeMemberType({ nodeType: "ElementaryTypeName", name: "uint256" }), + "uint256", + ); + }); + + it("returns undefined for elementary types with no name", () => { + assert.equal( + encodeMemberType({ nodeType: "ElementaryTypeName" }), + undefined, + ); + }); + }); + + describe("UserDefinedTypeName", () => { + it("encodes enums as uint8", () => { + assert.equal( + encodeMemberType({ + nodeType: "UserDefinedTypeName", + typeDescriptions: { typeString: "enum Status" }, + }), + "uint8", + ); + }); + + it("encodes contracts and interfaces as address", () => { + assert.equal( + encodeMemberType({ + nodeType: "UserDefinedTypeName", + typeDescriptions: { typeString: "contract IERC20" }, + }), + "address", + ); + assert.equal( + encodeMemberType({ + nodeType: "UserDefinedTypeName", + typeDescriptions: { typeString: "interface IFoo" }, + }), + "address", + ); + }); + + it("encodes structs as their bare name (qualified path is ignored)", () => { + assert.equal( + encodeMemberType({ + nodeType: "UserDefinedTypeName", + typeDescriptions: { typeString: "struct Wallet.Person" }, + }), + "Person", + ); + }); + + it("strips storage-location suffix from qualified struct names", () => { + // solc emits e.g. `struct MyLib.Bar memory` for memory-located struct + // references; we need just `Bar`. + assert.equal( + encodeMemberType({ + nodeType: "UserDefinedTypeName", + typeDescriptions: { typeString: "struct MyLib.Bar memory" }, + }), + "Bar", + ); + }); + + it("falls back to typeName.name for user-defined value types", () => { + // UDVTs (`type MyUint is uint256;`, solc 0.8.8+) emit a typeString that + // doesn't match enum/contract/interface/struct, so we fall back to the + // node's local `name`. + assert.equal( + encodeMemberType({ + nodeType: "UserDefinedTypeName", + name: "MyUint", + typeDescriptions: { typeString: "MyUint" }, + }), + "MyUint", + ); + }); + + it("falls back to typeName.pathNode.name when no local name is present", () => { + // Newer solc emits UserDefinedTypeName with a `pathNode` instead of a + // flat `name`; take the last segment of its qualified path. + assert.equal( + encodeMemberType({ + nodeType: "UserDefinedTypeName", + typeDescriptions: { typeString: "MyLib.MyUint" }, + pathNode: { name: "MyLib.MyUint" }, + }), + "MyUint", + ); + }); + + it("returns undefined when no recognized typeString, name, or pathNode is present", () => { + assert.equal( + encodeMemberType({ nodeType: "UserDefinedTypeName" }), + undefined, + ); + }); + }); + + describe("ArrayTypeName", () => { + it("returns undefined when the base type is not encodable", () => { + assert.equal( + encodeMemberType({ + nodeType: "ArrayTypeName", + baseType: { + nodeType: "Mapping", + keyType: { nodeType: "ElementaryTypeName", name: "address" }, + valueType: { nodeType: "ElementaryTypeName", name: "uint256" }, + }, + length: null, + }), + undefined, + ); + }); + + it("encodes dynamic struct arrays with []", () => { + assert.equal( + encodeMemberType({ + nodeType: "ArrayTypeName", + baseType: { + nodeType: "UserDefinedTypeName", + typeDescriptions: { typeString: "struct Item" }, + }, + length: null, + }), + "Item[]", + ); + }); + + it("encodes fixed-size arrays with [N] using the literal value", () => { + assert.equal( + encodeMemberType({ + nodeType: "ArrayTypeName", + baseType: { nodeType: "ElementaryTypeName", name: "uint256" }, + length: { nodeType: "Literal", value: "3" }, + }), + "uint256[3]", + ); + }); + + it("falls back to typeString for fixed-size with constant-expr length", () => { + assert.equal( + encodeMemberType({ + nodeType: "ArrayTypeName", + baseType: { nodeType: "ElementaryTypeName", name: "uint256" }, + length: { nodeType: "Identifier", name: "N" }, + typeDescriptions: { typeString: "uint256[5]" }, + }), + "uint256[5]", + ); + }); + + it("falls back to [] when length is non-literal and typeString has no resolved size", () => { + assert.equal( + encodeMemberType({ + nodeType: "ArrayTypeName", + baseType: { nodeType: "ElementaryTypeName", name: "uint256" }, + length: { nodeType: "Identifier", name: "N" }, + }), + "uint256[]", + ); + }); + }); + + describe("Mapping", () => { + it("returns undefined for mappings", () => { + assert.equal( + encodeMemberType({ + nodeType: "Mapping", + keyType: { nodeType: "ElementaryTypeName", name: "address" }, + valueType: { nodeType: "ElementaryTypeName", name: "uint256" }, + }), + undefined, + ); + }); + }); + + describe("FunctionTypeName", () => { + it("returns undefined for function types", () => { + assert.equal( + encodeMemberType({ nodeType: "FunctionTypeName" }), + undefined, + ); + }); + }); + + describe("default", () => { + it("returns undefined for unknown node types", () => { + assert.equal( + encodeMemberType({ nodeType: "SomeFutureTypeName" }), + undefined, + ); + }); + }); + }); +}); diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts new file mode 100644 index 00000000000..5b8d36e1184 --- /dev/null +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -0,0 +1,227 @@ +import type { CollectedStruct } from "../../../../../src/internal/builtin-plugins/solidity-test/eip712/ast-walker.js"; + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { HardhatError } from "@nomicfoundation/hardhat-errors"; +import { assertThrowsHardhatError } from "@nomicfoundation/hardhat-test-utils"; + +import { canonicalizeStructs } from "../../../../../src/internal/builtin-plugins/solidity-test/eip712/canonicalize.js"; + +function struct( + name: string, + members: Array<[string | undefined, string]>, + sourcePath = "test/Sample.sol", +): CollectedStruct { + return { + name, + sourcePath, + members: members.map(([type, memberName]) => ({ + type, + name: memberName, + })), + }; +} + +describe("eip712 - canonicalize", () => { + it("emits a single head for a primitives-only struct", () => { + const result = canonicalizeStructs([ + struct("Person", [ + ["address", "wallet"], + ["string", "name"], + ]), + ]); + + assert.deepEqual(result, ["Person(address wallet,string name)"]); + }); + + it("inlines deps and sorts them alphabetically (EIP-712 Mail example)", () => { + const collected = [ + struct("Mail", [ + ["Person", "from"], + ["Person", "to"], + ["string", "contents"], + ]), + struct("Person", [ + ["address", "wallet"], + ["string", "name"], + ]), + ]; + + const result = canonicalizeStructs(collected); + + assert.deepEqual(result, [ + "Mail(Person from,Person to,string contents)Person(address wallet,string name)", + "Person(address wallet,string name)", + ]); + }); + + it("emits one entry per struct with deps inlined (Transaction example)", () => { + const collected = [ + struct("Transaction", [ + ["Person", "from"], + ["Person", "to"], + ["Asset", "tx"], + ]), + struct("Asset", [ + ["address", "token"], + ["uint256", "amount"], + ]), + struct("Person", [ + ["address", "wallet"], + ["string", "name"], + ]), + ]; + + const result = canonicalizeStructs(collected); + + // Transaction with deps inlined alphabetically: Asset before Person. + assert.deepEqual(result, [ + "Transaction(Person from,Person to,Asset tx)" + + "Asset(address token,uint256 amount)" + + "Person(address wallet,string name)", + "Asset(address token,uint256 amount)", + "Person(address wallet,string name)", + ]); + }); + + it("merges direct and transitive deps into one sorted set", () => { + // Transaction directly references Asset and Person; Person itself + // references Wallet. The Transaction entry must pull in all three (Asset, + // Person, Wallet) and sort them as one merged set, not "directs first, + // transitives after". + const collected = [ + struct("Transaction", [ + ["Asset", "asset"], + ["Person", "person"], + ]), + struct("Asset", [["uint256", "amount"]]), + struct("Person", [["Wallet", "wallet"]]), + struct("Wallet", [["address", "addr"]]), + ]; + + const result = canonicalizeStructs(collected); + + assert.deepEqual(result, [ + "Transaction(Asset asset,Person person)" + + "Asset(uint256 amount)" + + "Person(Wallet wallet)" + + "Wallet(address addr)", + "Asset(uint256 amount)", + "Person(Wallet wallet)Wallet(address addr)", + "Wallet(address addr)", + ]); + }); + + it("dedupes identical struct definitions found in multiple sources", () => { + const collected = [ + struct("Person", [ + ["address", "wallet"], + ["string", "name"], + ]), + struct( + "Person", + [ + ["address", "wallet"], + ["string", "name"], + ], + "test/Other.sol", + ), + ]; + + const result = canonicalizeStructs(collected); + + assert.deepEqual(result, ["Person(address wallet,string name)"]); + }); + + it("throws on conflicting definitions for the same struct name", () => { + assertThrowsHardhatError( + () => + canonicalizeStructs([ + struct("Foo", [["uint256", "a"]], "test/A.sol"), + struct("Foo", [["uint256", "b"]], "test/B.sol"), + ]), + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, + { + name: "Foo", + firstSource: "test/A.sol", + secondSource: "test/B.sol", + }, + ); + }); + + it("drops structs with non-decodable members (e.g. mappings)", () => { + // Matches forge: `resolve_struct_eip712` returns `None` when any member + // has an unsupported type, so the struct is filtered out entirely rather + // than emitted with the bad member silently removed. + const result = canonicalizeStructs([ + struct("Holder", [ + ["uint256", "id"], + [undefined, "balances"], + ["address", "owner"], + ]), + ]); + + assert.deepEqual(result, []); + }); + + it("drops structs that transitively depend on non-decodable structs", () => { + // Holder has a mapping → non-decodable. + // Order references Holder → non-decodable too (None propagates through deps). + // Person is independent → still encodable. + const result = canonicalizeStructs([ + struct("Person", [ + ["address", "wallet"], + ["string", "name"], + ]), + struct("Holder", [ + ["uint256", "amount"], + [undefined, "balances"], + ]), + struct("Order", [ + ["uint256", "id"], + ["Holder", "holder"], + ]), + ]); + + assert.deepEqual(result, ["Person(address wallet,string name)"]); + }); + + it("treats array-of-struct members as a struct dep", () => { + const result = canonicalizeStructs([ + struct("Bag", [["Item[]", "items"]]), + struct("Item", [["uint256", "id"]]), + ]); + + assert.deepEqual(result, [ + "Bag(Item[] items)Item(uint256 id)", + "Item(uint256 id)", + ]); + }); + + it("strips fixed-size and nested array suffixes when resolving deps", () => { + const result = canonicalizeStructs([ + struct("Bag", [ + ["Item[3]", "fixed"], + ["Other[2][3]", "nested"], + ]), + struct("Item", [["uint256", "id"]]), + struct("Other", [["address", "who"]]), + ]); + + assert.deepEqual(result, [ + "Bag(Item[3] fixed,Other[2][3] nested)" + + "Item(uint256 id)" + + "Other(address who)", + "Item(uint256 id)", + "Other(address who)", + ]); + }); + + it("ignores self-references when computing deps", () => { + // `S` has a member of type `S[]` (legal in Solidity). The self-ref must + // not be emitted as a dep — only its name appears in the head. + const result = canonicalizeStructs([struct("S", [["S[]", "children"]])]); + assert.deepEqual(result, ["S(S[] children)"]); + }); +}); diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts new file mode 100644 index 00000000000..da7888a0287 --- /dev/null +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { isPathSelected } from "../../../../../src/internal/builtin-plugins/solidity-test/eip712/glob.js"; + +describe("eip712 - glob", () => { + describe("isPathSelected", () => { + it("matches nothing when include is empty", () => { + assert.equal(isPathSelected("a/b.sol", [], []), false); + }); + + it("ignores exclude when include is empty", () => { + // With no include there's nothing to narrow, so exclude is a no-op. + assert.equal(isPathSelected("a/b.sol", [], ["a/**"]), false); + assert.equal(isPathSelected("c/d.sol", [], ["a/**"]), false); + }); + + it("only includes paths matching at least one include glob", () => { + assert.equal( + isPathSelected("contracts/Foo.sol", ["contracts/**"], []), + true, + ); + assert.equal( + isPathSelected("tests/Foo.sol", ["contracts/**"], []), + false, + ); + }); + + it("excludes narrow the included set", () => { + assert.equal( + isPathSelected( + "contracts/Foo.sol", + ["contracts/**"], + ["contracts/Foo.sol"], + ), + false, + ); + }); + + it("includes paths matching include and not matching exclude", () => { + assert.equal( + isPathSelected( + "contracts/Foo.sol", + ["contracts/**"], + ["contracts/Bar.sol"], + ), + true, + ); + }); + + it("matches an exact path", () => { + assert.equal( + isPathSelected("tests/EIP712Types.sol", ["tests/EIP712Types.sol"], []), + true, + ); + }); + + it("does not match across directory boundaries with `*`", () => { + assert.equal(isPathSelected("a/b/c.sol", ["a/*.sol"], []), false); + assert.equal(isPathSelected("a/b.sol", ["a/*.sol"], []), true); + }); + + it("matches across directory boundaries with `**`", () => { + assert.equal(isPathSelected("a/b/c.sol", ["a/**.sol"], []), true); + assert.equal(isPathSelected("a/b/c/d.sol", ["a/**.sol"], []), true); + }); + + it("treats `?` as a single non-slash char", () => { + assert.equal(isPathSelected("a/b.sol", ["a/?.sol"], []), true); + assert.equal(isPathSelected("a/bb.sol", ["a/?.sol"], []), false); + assert.equal(isPathSelected("a//.sol", ["a/?.sol"], []), false); + }); + + it("escapes regex metacharacters in the literal portion", () => { + // The `.` should not match arbitrary characters. + assert.equal(isPathSelected("fooXsol", ["foo.sol"], []), false); + }); + + it("matches when any include pattern matches", () => { + assert.equal(isPathSelected("b.sol", ["a.sol", "b.sol"], []), true); + assert.equal(isPathSelected("c.sol", ["a.sol", "b.sol"], []), false); + }); + + it("exclude does not match across directory boundaries with `*`", () => { + assert.equal(isPathSelected("a/b/c.sol", ["**"], ["a/*.sol"]), true); + assert.equal(isPathSelected("a/b.sol", ["**"], ["a/*.sol"]), false); + }); + + it("exclude matches across directory boundaries with `**`", () => { + assert.equal(isPathSelected("a/b/c.sol", ["**"], ["a/**.sol"]), false); + assert.equal(isPathSelected("a/b/c/d.sol", ["**"], ["a/**.sol"]), false); + }); + + it("exclude treats `?` as a single non-slash char", () => { + assert.equal(isPathSelected("a/b.sol", ["**"], ["a/?.sol"]), false); + assert.equal(isPathSelected("a/bb.sol", ["**"], ["a/?.sol"]), true); + assert.equal(isPathSelected("a//.sol", ["**"], ["a/?.sol"]), true); + }); + + it("excludes when any exclude pattern matches", () => { + assert.equal(isPathSelected("b.sol", ["**"], ["a.sol", "b.sol"]), false); + assert.equal(isPathSelected("c.sol", ["**"], ["a.sol", "b.sol"]), true); + }); + }); +}); diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts new file mode 100644 index 00000000000..4e7d11f7c84 --- /dev/null +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -0,0 +1,357 @@ +import type { BuildInfoAndOutput } from "../../../../../src/internal/builtin-plugins/solidity-test/edr-artifacts.js"; + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { utf8StringToBytes } from "@nomicfoundation/hardhat-utils/bytes"; + +import { collectEip712CanonicalTypes } from "../../../../../src/internal/builtin-plugins/solidity-test/eip712/index.js"; + +interface FakeSource { + inputSourceName: string; + userSourceName: string; + ast: unknown; +} + +function makeBuildInfo( + buildInfoId: string, + sources: FakeSource[], +): BuildInfoAndOutput { + const userSourceNameMap: Record = {}; + for (const s of sources) { + userSourceNameMap[s.userSourceName] = s.inputSourceName; + } + const buildInfo = { + _format: "hh3-sol-build-info-1", + id: buildInfoId, + solcVersion: "0.8.23", + solcLongVersion: "0.8.23+commit.f704f362", + userSourceNameMap, + input: { language: "Solidity", sources: {}, settings: {} }, + }; + + const outputSources: Record = {}; + for (const s of sources) { + outputSources[s.inputSourceName] = { id: 0, ast: s.ast }; + } + + const output = { + _format: "hh3-sol-build-info-output-1", + id: buildInfoId, + output: { sources: outputSources }, + }; + + return { + buildInfoId, + buildInfo: utf8StringToBytes(JSON.stringify(buildInfo)), + output: utf8StringToBytes(JSON.stringify(output)), + }; +} + +function structAst( + name: string, + members: Array<{ type: string; name: string }>, +): unknown { + return { + nodeType: "StructDefinition", + name, + members: members.map((m) => ({ + nodeType: "VariableDeclaration", + name: m.name, + typeName: m.type.endsWith("]") + ? buildArrayTypeName(m.type) + : isElementary(m.type) + ? { nodeType: "ElementaryTypeName", name: m.type } + : { + nodeType: "UserDefinedTypeName", + typeDescriptions: { typeString: `struct ${m.type}` }, + }, + })), + }; +} + +function isElementary(t: string): boolean { + return /^(address|bool|string|bytes\d*|u?int\d*)$/.test(t); +} + +function buildArrayTypeName(t: string): unknown { + // `Foo[]` or `Foo[3]` + const match = /^(.+)\[([^\]]*)\]$/.exec(t); + if (match === null) { + throw new Error(`bad array type: ${t}`); + } + + const baseType = match[1]; + const length = match[2]; + + return { + nodeType: "ArrayTypeName", + baseType: isElementary(baseType) + ? { nodeType: "ElementaryTypeName", name: baseType } + : { + nodeType: "UserDefinedTypeName", + typeDescriptions: { typeString: `struct ${baseType}` }, + }, + length: length === "" ? null : { nodeType: "Literal", value: length }, + }; +} + +function sourceUnit(structs: unknown[]): unknown { + return { nodeType: "SourceUnit", nodes: structs }; +} + +describe("eip712 - collectEip712CanonicalTypes", () => { + it("returns an empty list when no include is configured", () => { + // The feature is opt-in: without `include`, collection short-circuits + // before any build info is parsed. + const buildInfo = makeBuildInfo("solc-0_8_23-00000000", [ + { + inputSourceName: "project/test/Types.sol", + userSourceName: "test/Types.sol", + ast: sourceUnit([structAst("Foo", [{ type: "uint256", name: "x" }])]), + }, + ]); + + // All possible scenarios where `include` is empty/unset: + assert.deepEqual(collectEip712CanonicalTypes([buildInfo], undefined), []); + assert.deepEqual(collectEip712CanonicalTypes([buildInfo], {}), []); + assert.deepEqual( + collectEip712CanonicalTypes([buildInfo], { include: [] }), + [], + ); + // Exclude alone is a no-op without an include to narrow. + assert.deepEqual( + collectEip712CanonicalTypes([buildInfo], { exclude: ["**"] }), + [], + ); + }); + + it("returns the flat canonical list for a Mail/Person fixture", () => { + const buildInfo = makeBuildInfo("solc-0_8_23-aaaaaaaa", [ + { + inputSourceName: "project/test/Types.sol", + userSourceName: "test/Types.sol", + ast: sourceUnit([ + structAst("Mail", [ + { type: "Person", name: "from" }, + { type: "Person", name: "to" }, + { type: "string", name: "contents" }, + ]), + structAst("Person", [ + { type: "address", name: "wallet" }, + { type: "string", name: "name" }, + ]), + ]), + }, + ]); + + const result = collectEip712CanonicalTypes([buildInfo], { + include: ["test/**"], + }); + + assert.deepEqual(result, [ + "Mail(Person from,Person to,string contents)Person(address wallet,string name)", + "Person(address wallet,string name)", + ]); + }); + + it("filters by include/exclude on the user source name", () => { + const buildInfo = makeBuildInfo("solc-0_8_23-bbbbbbbb", [ + { + inputSourceName: "project/contracts/Foo.sol", + userSourceName: "contracts/Foo.sol", + ast: sourceUnit([structAst("Foo", [{ type: "uint256", name: "x" }])]), + }, + { + inputSourceName: "project/test/Bar.sol", + userSourceName: "test/Bar.sol", + ast: sourceUnit([structAst("Bar", [{ type: "uint256", name: "y" }])]), + }, + ]); + + const onlyTests = collectEip712CanonicalTypes([buildInfo], { + include: ["test/**"], + }); + assert.deepEqual(onlyTests, ["Bar(uint256 y)"]); + + const excludeTests = collectEip712CanonicalTypes([buildInfo], { + include: ["**"], + exclude: ["test/**"], + }); + assert.deepEqual(excludeTests, ["Foo(uint256 x)"]); + }); + + it("dedupes the same struct seen across multiple build infos", () => { + const ast = sourceUnit([ + structAst("Person", [ + { type: "address", name: "wallet" }, + { type: "string", name: "name" }, + ]), + ]); + const a = makeBuildInfo("solc-0_8_23-cccccccc", [ + { + inputSourceName: "project/test/Types.sol", + userSourceName: "test/Types.sol", + ast, + }, + ]); + const b = makeBuildInfo("solc-0_8_23-dddddddd", [ + { + inputSourceName: "project/test/Types.sol", + userSourceName: "test/Types.sol", + ast, + }, + ]); + + const result = collectEip712CanonicalTypes([a, b], { + include: ["test/**"], + }); + + assert.deepEqual(result, ["Person(address wallet,string name)"]); + }); + + it("resolves user source names across build infos", () => { + // Mirrors `hardhat test solidity `: the partial build + // info's `userSourceNameMap` only registers the explicitly requested + // file, but its output contains the full transitive source set. The + // user-source name for the transitive source must come from the full + // build info that ran earlier. + const sharedAst = sourceUnit([ + structAst("Person", [ + { type: "address", name: "wallet" }, + { type: "string", name: "name" }, + ]), + ]); + const fullBuild = makeBuildInfo("solc-0_8_23-ffffffff", [ + { + inputSourceName: "project/contracts/Types.sol", + userSourceName: "contracts/Types.sol", + ast: sourceUnit([]), // not the source we care about here + }, + ]); + const partialBuildId = "solc-0_8_23-aaaa1111"; + const partialBuildInfo = { + _format: "hh3-sol-build-info-1", + id: partialBuildId, + solcVersion: "0.8.23", + solcLongVersion: "0.8.23+commit.f704f362", + userSourceNameMap: { + "test/Foo.t.sol": "project/test/Foo.t.sol", + }, + input: { language: "Solidity", sources: {}, settings: {} }, + }; + const partialOutput = { + _format: "hh3-sol-build-info-output-1", + id: partialBuildId, + output: { + sources: { + // Pulled in transitively, but not in this build info's own map. + "project/contracts/Types.sol": { id: 0, ast: sharedAst }, + }, + }, + }; + + const result = collectEip712CanonicalTypes( + [ + fullBuild, + { + buildInfoId: partialBuildId, + buildInfo: utf8StringToBytes(JSON.stringify(partialBuildInfo)), + output: utf8StringToBytes(JSON.stringify(partialOutput)), + }, + ], + { include: ["contracts/**"] }, + ); + + assert.deepEqual(result, ["Person(address wallet,string name)"]); + }); + + it("falls back to inputSourceName when userSourceNameMap omits an entry", () => { + // Imported sources (e.g. from npm packages) aren't registered as roots, + // so they don't appear in `userSourceNameMap`. The orchestrator should + // still surface their structs, keyed by the input source name. + const buildInfoId = "solc-0_8_23-eeeeeeee"; + const buildInfo = { + _format: "hh3-sol-build-info-1", + id: buildInfoId, + solcVersion: "0.8.23", + solcLongVersion: "0.8.23+commit.f704f362", + userSourceNameMap: {}, // no roots + input: { language: "Solidity", sources: {}, settings: {} }, + }; + const output = { + _format: "hh3-sol-build-info-output-1", + id: buildInfoId, + output: { + sources: { + "npm/some-pkg/Types.sol": { + id: 0, + ast: sourceUnit([ + structAst("Imported", [{ type: "uint256", name: "x" }]), + ]), + }, + }, + }, + }; + + const result = collectEip712CanonicalTypes( + [ + { + buildInfoId, + buildInfo: utf8StringToBytes(JSON.stringify(buildInfo)), + output: utf8StringToBytes(JSON.stringify(output)), + }, + ], + { include: ["npm/**"] }, + ); + + assert.deepEqual(result, ["Imported(uint256 x)"]); + }); + + it("skips build infos whose output has no sources", () => { + // Defensive: a build info output without a `sources` key must be + // silently skipped, and structs from sibling build infos must still + // be collected. + const emptyBuildInfoId = "solc-0_8_23-88888888"; + const emptyBuildInfo = { + _format: "hh3-sol-build-info-1", + id: emptyBuildInfoId, + solcVersion: "0.8.23", + solcLongVersion: "0.8.23+commit.f704f362", + userSourceNameMap: {}, + input: { language: "Solidity", sources: {}, settings: {} }, + }; + const emptyOutput = { + _format: "hh3-sol-build-info-output-1", + id: emptyBuildInfoId, + output: {}, // no `sources` key + }; + + const goodBuildInfo = makeBuildInfo("solc-0_8_23-99999999", [ + { + inputSourceName: "project/test/Types.sol", + userSourceName: "test/Types.sol", + ast: sourceUnit([ + structAst("Person", [ + { type: "address", name: "wallet" }, + { type: "string", name: "name" }, + ]), + ]), + }, + ]); + + const result = collectEip712CanonicalTypes( + [ + { + buildInfoId: emptyBuildInfoId, + buildInfo: utf8StringToBytes(JSON.stringify(emptyBuildInfo)), + output: utf8StringToBytes(JSON.stringify(emptyOutput)), + }, + goodBuildInfo, + ], + { include: ["test/**"] }, + ); + + assert.deepEqual(result, ["Person(address wallet,string name)"]); + }); +}); From 08805db44549f1ee2175854a456d94a849e7a839 Mon Sep 17 00:00:00 2001 From: Christopher Dedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Tue, 5 May 2026 12:02:33 +0200 Subject: [PATCH 02/34] Create good-islands-hide.md --- .changeset/good-islands-hide.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/good-islands-hide.md diff --git a/.changeset/good-islands-hide.md b/.changeset/good-islands-hide.md new file mode 100644 index 00000000000..d1fa36b6052 --- /dev/null +++ b/.changeset/good-islands-hide.md @@ -0,0 +1,6 @@ +--- +"@nomicfoundation/hardhat-errors": patch +"hardhat": patch +--- + +Support EIP-712 cheatcodes in Solidity Test. From 9473d0636a9c3145f43bcdc7611a0e2c84501a5f Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Tue, 5 May 2026 12:20:44 +0000 Subject: [PATCH 03/34] fix spelling issues --- .../builtin-plugins/solidity-test/eip712/ast-walker.ts | 4 ++-- .../builtin-plugins/solidity-test/eip712/canonicalize.ts | 2 +- .../internal/builtin-plugins/solidity-test/eip712/glob.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index fb23be029ca..0870d6a7d6a 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -233,7 +233,7 @@ describe("eip712 - ast-walker", () => { assert.equal( encodeMemberType({ nodeType: "UserDefinedTypeName", - typeDescriptions: { typeString: "contract IERC20" }, + typeDescriptions: { typeString: "contract MyToken" }, }), "address", ); @@ -269,7 +269,7 @@ describe("eip712 - ast-walker", () => { }); it("falls back to typeName.name for user-defined value types", () => { - // UDVTs (`type MyUint is uint256;`, solc 0.8.8+) emit a typeString that + // User-defined value types (`type MyUint is uint256;`, solc 0.8.8+) emit a typeString that // doesn't match enum/contract/interface/struct, so we fall back to the // node's local `name`. assert.equal( diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts index 5b8d36e1184..6aec604be6a 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -35,7 +35,7 @@ describe("eip712 - canonicalize", () => { assert.deepEqual(result, ["Person(address wallet,string name)"]); }); - it("inlines deps and sorts them alphabetically (EIP-712 Mail example)", () => { + it("appends deps and sorts them alphabetically (EIP-712 Mail example)", () => { const collected = [ struct("Mail", [ ["Person", "from"], diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts index da7888a0287..2230de5b707 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts @@ -73,7 +73,7 @@ describe("eip712 - glob", () => { it("escapes regex metacharacters in the literal portion", () => { // The `.` should not match arbitrary characters. - assert.equal(isPathSelected("fooXsol", ["foo.sol"], []), false); + assert.equal(isPathSelected("foo1sol", ["foo.sol"], []), false); }); it("matches when any include pattern matches", () => { From 061f5e7292adee40a19d8c7526ef889db5e262c2 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Thu, 7 May 2026 12:15:23 +0000 Subject: [PATCH 04/34] add link to website docs --- .changeset/good-islands-hide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/good-islands-hide.md b/.changeset/good-islands-hide.md index d1fa36b6052..b08d0fae46f 100644 --- a/.changeset/good-islands-hide.md +++ b/.changeset/good-islands-hide.md @@ -1,4 +1,5 @@ --- +# docs: https://github.com/NomicFoundation/hardhat-website/pull/260 "@nomicfoundation/hardhat-errors": patch "hardhat": patch --- From a5d409073164cabbe2d1d63354b1d3e5c9b28849 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Thu, 7 May 2026 13:33:25 +0000 Subject: [PATCH 05/34] cache the glob results --- .../builtin-plugins/solidity-test/eip712/glob.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts index 0270359f2c7..faf828e16c2 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts @@ -1,3 +1,6 @@ +// The same patterns are reused for every source file, so compile each one once. +const compiledGlobCache = new Map(); + /** * Returns true when `path` should be included given user-supplied include and * exclude glob lists. `include` is the gate: an empty `include` matches @@ -28,7 +31,7 @@ export function isPathSelected( */ function matchesAny(value: string, patterns: string[]): boolean { for (const pattern of patterns) { - if (globToRegExp(pattern).test(value)) { + if (getCompiledGlob(pattern).test(value)) { return true; } } @@ -73,3 +76,14 @@ function globToRegExp(pattern: string): RegExp { return new RegExp(regex); } + +function getCompiledGlob(pattern: string): RegExp { + let compiled = compiledGlobCache.get(pattern); + + if (compiled === undefined) { + compiled = globToRegExp(pattern); + compiledGlobCache.set(pattern, compiled); + } + + return compiled; +} From 96a94d7bab164bb060bdee51e4ca80aac469672d Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Thu, 7 May 2026 15:03:40 +0000 Subject: [PATCH 06/34] fix EIP-712 collector pre-dedupe hiding same-file struct conflicts --- .../solidity-test/eip712/index.ts | 15 +--- .../solidity-test/eip712/index.ts | 89 +++++++++++++++++++ 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts index f6464459661..4238d3c69e4 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -56,9 +56,6 @@ export function collectEip712CanonicalTypes( } } - // The same source can be compiled into more than one build info, so - // dedupe structs by (sourceName, structName) before collecting them. - const seen = new Set(); const collected: CollectedStruct[] = []; for (const { output } of parsed) { @@ -75,17 +72,7 @@ export function collectEip712CanonicalTypes( continue; } - const structs = extractStructsFromAst(source.ast, userSourceName); - for (const struct of structs) { - const key = `${userSourceName}::${struct.name}`; - - if (seen.has(key)) { - continue; - } - - seen.add(key); - collected.push(struct); - } + collected.push(...extractStructsFromAst(source.ast, userSourceName)); } } diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts index 4e7d11f7c84..8703418dbef 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -3,6 +3,8 @@ import type { BuildInfoAndOutput } from "../../../../../src/internal/builtin-plu import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import { HardhatError } from "@nomicfoundation/hardhat-errors"; +import { assertThrowsHardhatError } from "@nomicfoundation/hardhat-test-utils"; import { utf8StringToBytes } from "@nomicfoundation/hardhat-utils/bytes"; import { collectEip712CanonicalTypes } from "../../../../../src/internal/builtin-plugins/solidity-test/eip712/index.js"; @@ -100,6 +102,14 @@ function sourceUnit(structs: unknown[]): unknown { return { nodeType: "SourceUnit", nodes: structs }; } +function contractAst(name: string, structs: unknown[]): unknown { + return { + nodeType: "ContractDefinition", + name, + nodes: structs, + }; +} + describe("eip712 - collectEip712CanonicalTypes", () => { it("returns an empty list when no include is configured", () => { // The feature is opt-in: without `include`, collection short-circuits @@ -308,6 +318,85 @@ describe("eip712 - collectEip712CanonicalTypes", () => { assert.deepEqual(result, ["Imported(uint256 x)"]); }); + it("throws on conflicting same-named structs within a single source file", () => { + // A top-level `struct S` and a `contract C { struct S { ... } }` with + // a different definition share a source path but produce different + // EIP-712 heads. Since `vm.eip712HashType` resolves by bare name, this + // is genuinely ambiguous and must surface as an error rather than be + // silently resolved by AST traversal order. + const buildInfo = makeBuildInfo("solc-0_8_23-11111111", [ + { + inputSourceName: "project/test/Types.sol", + userSourceName: "test/Types.sol", + ast: sourceUnit([ + structAst("S", [{ type: "uint256", name: "a" }]), + contractAst("C", [structAst("S", [{ type: "uint256", name: "b" }])]), + ]), + }, + ]); + + assertThrowsHardhatError( + () => + collectEip712CanonicalTypes([buildInfo], { + include: ["test/**"], + }), + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, + { + name: "S", + firstSource: "test/Types.sol", + secondSource: "test/Types.sol", + }, + ); + }); + + it("throws on conflicting same-named structs across two contracts in one file", () => { + const buildInfo = makeBuildInfo("solc-0_8_23-22222222", [ + { + inputSourceName: "project/test/Types.sol", + userSourceName: "test/Types.sol", + ast: sourceUnit([ + contractAst("A", [structAst("S", [{ type: "uint256", name: "a" }])]), + contractAst("B", [structAst("S", [{ type: "uint256", name: "b" }])]), + ]), + }, + ]); + + assertThrowsHardhatError( + () => + collectEip712CanonicalTypes([buildInfo], { + include: ["test/**"], + }), + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, + { + name: "S", + firstSource: "test/Types.sol", + secondSource: "test/Types.sol", + }, + ); + }); + + it("dedupes identical same-named structs within a single source file", () => { + // A top-level `struct S` and a `contract C { struct S { ... } }` with + // an identical definition produce the same EIP-712 head; that's not a + // conflict and must be silently deduped. + const buildInfo = makeBuildInfo("solc-0_8_23-33333333", [ + { + inputSourceName: "project/test/Types.sol", + userSourceName: "test/Types.sol", + ast: sourceUnit([ + structAst("S", [{ type: "uint256", name: "a" }]), + contractAst("C", [structAst("S", [{ type: "uint256", name: "a" }])]), + ]), + }, + ]); + + const result = collectEip712CanonicalTypes([buildInfo], { + include: ["test/**"], + }); + + assert.deepEqual(result, ["S(uint256 a)"]); + }); + it("skips build infos whose output has no sources", () => { // Defensive: a build info output without a `sources` key must be // silently skipped, and structs from sibling build infos must still From 7a56f67f4c148ca9e7ba6fae338ced399fc6d679 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Thu, 7 May 2026 15:40:26 +0000 Subject: [PATCH 07/34] fix EIP-712 indexByName dropping encodable structs on unsupported-member collisions --- packages/hardhat-errors/src/descriptors.ts | 2 +- .../solidity-test/eip712/canonicalize.ts | 41 +++++++-- .../solidity-test/eip712/canonicalize.ts | 90 +++++++++++++++++++ 3 files changed, 123 insertions(+), 10 deletions(-) diff --git a/packages/hardhat-errors/src/descriptors.ts b/packages/hardhat-errors/src/descriptors.ts index b4f7b6e1568..0f264a056c2 100644 --- a/packages/hardhat-errors/src/descriptors.ts +++ b/packages/hardhat-errors/src/descriptors.ts @@ -1251,7 +1251,7 @@ Double-check the paths you are providing to the \`test solidity\` task.`, 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 produced different EIP-712 type strings. Type-name lookups via \`vm.eip712HashType\` and \`vm.eip712HashStruct\` would be ambiguous.`, + websiteDescription: `Two struct definitions with the same name had different members. Type-name lookups via \`vm.eip712HashType\` and \`vm.eip712HashStruct\` would be ambiguous.`, }, }, SOLIDITY: { diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts index 09983efc301..572f4463dbe 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -181,27 +181,35 @@ function transitiveDeps( /** * Builds a name → definition map from collected structs, throwing - * `EIP712_DUPLICATE_STRUCT_NAME` when two structs share a name but produce - * different `encodeType` heads. Identical heads are silently deduplicated - * (same struct seen across multiple build infos / partial recompiles). + * `EIP712_DUPLICATE_STRUCT_NAME` when two structs share a name but have + * different member lists. Definitions with identical members are silently + * deduplicated (same struct seen across multiple build infos / partial + * recompiles). + * + * The comparison uses the full member list — including members whose type + * isn't EIP-712 encodable (mappings, function types) — so that two structs + * differing only in unsupported members are detected as conflicting rather + * than collapsed into one. Comparing only the encoded head would let a + * non decodable definition silently win over an encodable one, dropping the + * struct from the canonical output. */ function indexByName(structs: CollectedStruct[]): Map { const byName = new Map(); - const headByName = new Map(); + const fingerprintByName = new Map(); const sourceByName = new Map(); for (const struct of structs) { - const head = encodeStructHead(struct); - const existingHead = headByName.get(struct.name); + const fingerprint = fingerprintStruct(struct); + const existingFingerprint = fingerprintByName.get(struct.name); - if (existingHead === undefined) { + if (existingFingerprint === undefined) { byName.set(struct.name, struct); - headByName.set(struct.name, head); + fingerprintByName.set(struct.name, fingerprint); sourceByName.set(struct.name, struct.sourcePath); continue; } - if (existingHead === head) { + if (existingFingerprint === fingerprint) { continue; } @@ -217,3 +225,18 @@ function indexByName(structs: CollectedStruct[]): Map { return byName; } + +/** + * A stable string capturing every member of `struct`, used to detect + * conflicting definitions of the same name. Unlike `encodeStructHead`, this + * preserves members whose type can't be EIP-712 encoded — they're emitted with + * an `` sentinel — so structs that differ only in mapping or + * function-type members aren't collapsed together. + */ +function fingerprintStruct(struct: CollectedStruct): string { + const segments = struct.members.map( + (m) => `${m.type ?? ""} ${m.name}`, + ); + + return `${struct.name}(${segments.join(",")})`; +} diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts index 6aec604be6a..c47f5dd4269 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -150,6 +150,96 @@ describe("eip712 - canonicalize", () => { ); }); + it("throws when same-named structs differ only in an unsupported member", () => { + // Both definitions encode to `Foo(address from)` once mapping members are + // dropped, but they aren't the same struct. Comparing only the encoded + // head would let the non decodable definition silently win, dropping `Foo` + // from the output even though an encodable definition exists. + assertThrowsHardhatError( + () => + canonicalizeStructs([ + struct( + "Foo", + [ + ["address", "from"], + [undefined, "balances"], + ], + "test/A.sol", + ), + struct("Foo", [["address", "from"]], "test/B.sol"), + ]), + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, + { + name: "Foo", + firstSource: "test/A.sol", + secondSource: "test/B.sol", + }, + ); + }); + + it("throws when same-named structs differ only in which member is unsupported", () => { + // Both heads collapse to `Foo(address from,address to)` once the mapping + // is dropped, but the structs are clearly different definitions. + assertThrowsHardhatError( + () => + canonicalizeStructs([ + struct( + "Foo", + [ + ["address", "from"], + [undefined, "m"], + ["address", "to"], + ], + "test/A.sol", + ), + struct( + "Foo", + [ + ["address", "from"], + ["address", "to"], + ], + "test/B.sol", + ), + ]), + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, + { + name: "Foo", + firstSource: "test/A.sol", + secondSource: "test/B.sol", + }, + ); + }); + + it("dedupes same-named structs that share unsupported members exactly", () => { + // Same struct seen in two build infos (e.g. partial recompiles): the + // unsupported member appears in both, at the same position, with the same + // name. Treat as one definition. + const collected = [ + struct( + "Holder", + [ + ["uint256", "amount"], + [undefined, "balances"], + ], + "test/A.sol", + ), + struct( + "Holder", + [ + ["uint256", "amount"], + [undefined, "balances"], + ], + "test/B.sol", + ), + ]; + + // Both copies are non decodable (mapping member), so the canonical output is + // empty — the important part is that canonicalization doesn't throw. + const result = canonicalizeStructs(collected); + + assert.deepEqual(result, []); + }); + it("drops structs with non-decodable members (e.g. mappings)", () => { // Matches forge: `resolve_struct_eip712` returns `None` when any member // has an unsupported type, so the struct is filtered out entirely rather From 692c71ef40775adceb02d179f460b44d33540420 Mon Sep 17 00:00:00 2001 From: Christopher Dedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Thu, 7 May 2026 17:53:13 +0200 Subject: [PATCH 08/34] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../builtin-plugins/solidity-test/eip712/canonicalize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts index 572f4463dbe..efe597ada9c 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -59,7 +59,7 @@ export function canonicalizeStructs(structs: CollectedStruct[]): string[] { /** * Returns the set of struct names that are EIP-712 encodable. A struct is - * encodable if none of its members has an non-decodable type (`type === undefined`, + * encodable if none of its members has a non-decodable type (`type === undefined`, * e.g. mappings or function types) AND every one of its struct deps — direct or * transitive — is itself encodable. */ From c71f117b77a3ff30503221f30d456d0309edc66b Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 8 May 2026 09:59:52 +0000 Subject: [PATCH 09/34] fix EIP-712 collector dropping transitive project files from include globs --- .../solidity-test/eip712/index.ts | 19 +++++++- .../resolver/remapped-npm-packages-graph.ts | 3 +- .../src/types/solidity/solidity-artifacts.ts | 11 +++++ .../solidity-test/eip712/index.ts | 45 +++++++++++++++++++ 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts index 4238d3c69e4..34ec7c51397 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -7,6 +7,8 @@ import type { BuildInfoAndOutput } from "../edr-artifacts.js"; import { bytesToUtf8String } from "@nomicfoundation/hardhat-utils/bytes"; +import { HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT } from "../../../../types/solidity/solidity-artifacts.js"; + import { extractStructsFromAst } from "./ast-walker.js"; import { canonicalizeStructs } from "./canonicalize.js"; import { isPathSelected } from "./glob.js"; @@ -16,6 +18,12 @@ export interface Eip712TypesConfig { exclude?: string[]; } +// When a transitive project file isn't a root in any build info — and so +// is missing from every userSourceNameMap — stripping this prefix recovers +// the user-facing path that the user's include/exclude globs are written +// against. +const PROJECT_INPUT_SOURCE_NAME_PREFIX = `${HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT}/`; + /** * Walks every compiled source's AST whose user source name matches the * configured `include` globs (and isn't matched by `exclude`), extracts @@ -65,8 +73,15 @@ export function collectEip712CanonicalTypes( } for (const [inputSourceName, source] of Object.entries(sources)) { - const userSourceName = - inputToUserSource.get(inputSourceName) ?? inputSourceName; + let userSourceName = inputToUserSource.get(inputSourceName); + + if (userSourceName === undefined) { + userSourceName = inputSourceName.startsWith( + PROJECT_INPUT_SOURCE_NAME_PREFIX, + ) + ? inputSourceName.slice(PROJECT_INPUT_SOURCE_NAME_PREFIX.length) + : inputSourceName; + } if (!isPathSelected(userSourceName, include, exclude)) { continue; diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/remapped-npm-packages-graph.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/remapped-npm-packages-graph.ts index db5df8648ee..0ae63b29890 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/remapped-npm-packages-graph.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/remapped-npm-packages-graph.ts @@ -28,6 +28,7 @@ import { type PackageJson, } from "@nomicfoundation/hardhat-utils/package"; +import { HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT } from "../../../../../types/solidity/solidity-artifacts.js"; import { UserRemappingErrorType } from "../../../../../types/solidity.js"; import { getNpmPackageName } from "./npm-module-parsing.js"; @@ -35,8 +36,6 @@ import { parseRemappingString, selectBestRemapping } from "./remappings.js"; import { sourceNamePathJoin } from "./source-name-utils.js"; import { UserRemappingType } from "./types.js"; -const HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT = "project"; - /** * Returns a normalized version of the path if it refers to a node_modules in * the root directory (i.e. node_modules/...), or a `node_modules` directory diff --git a/packages/hardhat/src/types/solidity/solidity-artifacts.ts b/packages/hardhat/src/types/solidity/solidity-artifacts.ts index 32a2c4a736e..ad161195fa5 100644 --- a/packages/hardhat/src/types/solidity/solidity-artifacts.ts +++ b/packages/hardhat/src/types/solidity/solidity-artifacts.ts @@ -1,5 +1,16 @@ import type { CompilerInput, CompilerOutput } from "./compiler-io.js"; +/** + * The input source name prefix used for files belonging to the Hardhat + * project (as opposed to npm packages, which use `npm/@`). + * + * For example, a project file at `/contracts/Foo.sol` has input + * source name `project/contracts/Foo.sol`. Part of the build-info format + * contract: changing this value would require a new build-info format + * version. + */ +export const HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT = "project"; + /** * A record with the versions of the different tools used to create a * build info. diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts index 8703418dbef..44b9932c8c0 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -318,6 +318,51 @@ describe("eip712 - collectEip712CanonicalTypes", () => { assert.deepEqual(result, ["Imported(uint256 x)"]); }); + it("strips the project/ prefix when a project file is missing from every userSourceNameMap", () => { + // A project file outside the standard root directories (e.g. a shared + // file in `lib/` that's only ever imported, never compiled as a root) + // never appears in any build info's userSourceNameMap. Its input source + // name is `project/lib/Helper.sol`. Falling back to that raw path would + // make user globs like `lib/**` miss it. The collector strips the + // `project/` prefix to recover the user-facing path. + const buildInfoId = "solc-0_8_23-cccccccc"; + const buildInfo = { + _format: "hh3-sol-build-info-1", + id: buildInfoId, + solcVersion: "0.8.23", + solcLongVersion: "0.8.23+commit.f704f362", + userSourceNameMap: {}, // transitive project file: not a root anywhere + input: { language: "Solidity", sources: {}, settings: {} }, + }; + const output = { + _format: "hh3-sol-build-info-output-1", + id: buildInfoId, + output: { + sources: { + "project/lib/Helper.sol": { + id: 0, + ast: sourceUnit([ + structAst("Helper", [{ type: "uint256", name: "n" }]), + ]), + }, + }, + }, + }; + + const result = collectEip712CanonicalTypes( + [ + { + buildInfoId, + buildInfo: utf8StringToBytes(JSON.stringify(buildInfo)), + output: utf8StringToBytes(JSON.stringify(output)), + }, + ], + { include: ["lib/**"] }, + ); + + assert.deepEqual(result, ["Helper(uint256 n)"]); + }); + it("throws on conflicting same-named structs within a single source file", () => { // A top-level `struct S` and a `contract C { struct S { ... } }` with // a different definition share a source path but produce different From dcf064687b79b31874b598571116536843a9bbc7 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Tue, 12 May 2026 09:33:43 +0000 Subject: [PATCH 10/34] test EIP-712 glob matcher against npm-rooted source names --- .../solidity-test/eip712/glob.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts index 2230de5b707..1777229c0fb 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts @@ -101,5 +101,51 @@ describe("eip712 - glob", () => { assert.equal(isPathSelected("b.sol", ["**"], ["a.sol", "b.sol"]), false); assert.equal(isPathSelected("c.sol", ["**"], ["a.sol", "b.sol"]), true); }); + + it("matches scoped npm package paths", () => { + assert.equal( + isPathSelected( + "@openzeppelin/contracts/token/ERC20/ERC20.sol", + ["@openzeppelin/**"], + [], + ), + true, + ); + assert.equal( + isPathSelected( + "@openzeppelin/contracts/token/ERC20/ERC20.sol", + ["@openzeppelin/contracts/token/**/*.sol"], + [], + ), + true, + ); + assert.equal( + isPathSelected( + "@openzeppelin/contracts/token/ERC20/ERC20.sol", + ["@other/**"], + [], + ), + false, + ); + }); + + it("excludes scoped npm package paths", () => { + assert.equal( + isPathSelected( + "@openzeppelin/contracts/mocks/Mock.sol", + ["@openzeppelin/**"], + ["@openzeppelin/contracts/mocks/**"], + ), + false, + ); + assert.equal( + isPathSelected( + "@openzeppelin/contracts/token/ERC20.sol", + ["@openzeppelin/**"], + ["@openzeppelin/contracts/mocks/**"], + ), + true, + ); + }); }); }); From a187b09d98ab04ac2759c74113807a0c4974e8cb Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Tue, 12 May 2026 10:07:13 +0000 Subject: [PATCH 11/34] UDVTs resolves underlying types --- .../solidity-test/eip712/ast-walker.ts | 101 +++++++- .../solidity-test/eip712/index.ts | 17 +- .../solidity-test/eip712/ast-walker.ts | 235 +++++++++++++++++- .../solidity-test/eip712/index.ts | 98 ++++++++ 4 files changed, 437 insertions(+), 14 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index fbfcbeea69d..cc219479a0d 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -23,6 +23,65 @@ export interface CollectedStruct { 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 UDVT definition's solc node id, which is what + * `referencedDeclaration` on a `UserDefinedTypeName` reference points to. + */ +export type UdvtIndex = Map>; + +/** + * Walks every AST in the build and indexes every `UserDefinedValueTypeDefinition` + * by its node `id`, mapping to its `underlyingType` (an `ElementaryTypeName`). + * + * UDVTs can sit at file scope or inside a contract, and a struct in one source + * may reference a UDVT 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 buildUdvtIndex(asts: unknown[]): UdvtIndex { + const index: UdvtIndex = 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") { + recordUdvt(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" + ) { + recordUdvt(member, index); + } + } + } + } + } + + return index; +} + +function recordUdvt(node: Record, index: UdvtIndex): 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. @@ -30,6 +89,7 @@ export interface CollectedStruct { export function extractStructsFromAst( ast: unknown, sourcePath: string, + udvtIndex: UdvtIndex = new Map(), ): CollectedStruct[] { if (!isObject(ast) || ast.nodeType !== "SourceUnit") { return []; @@ -44,7 +104,7 @@ export function extractStructsFromAst( } if (node.nodeType === "StructDefinition") { - const collected = collectStruct(node, sourcePath); + const collected = collectStruct(node, sourcePath, udvtIndex); if (collected !== undefined) { results.push(collected); } @@ -52,7 +112,7 @@ export function extractStructsFromAst( 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); + const collected = collectStruct(member, sourcePath, udvtIndex); if (collected !== undefined) { results.push(collected); } @@ -67,6 +127,7 @@ export function extractStructsFromAst( function collectStruct( node: Record, sourcePath: string, + udvtIndex: UdvtIndex, ): CollectedStruct | undefined { if (typeof node.name !== "string") { return undefined; @@ -91,7 +152,7 @@ function collectStruct( members.push({ name: memberNode.name, - type: encodeMemberType(memberNode.typeName), + type: encodeMemberType(memberNode.typeName, udvtIndex), }); } @@ -110,10 +171,14 @@ function collectStruct( * - 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): string | undefined { +export function encodeMemberType( + typeName: unknown, + udvtIndex: UdvtIndex = new Map(), +): string | undefined { if (!isObject(typeName)) { return undefined; } @@ -154,9 +219,29 @@ export function encodeMemberType(typeName: unknown): string | undefined { return segments[segments.length - 1]; } - // Fallback for user-defined value types (solc 0.8.8+) and type aliases. - // Some of these aren't EIP-712 encodable; emitting the name lets the - // downstream encoder produce a clear error rather than failing here. + // User-defined value types (`type Foo is bytes32;`, solc 0.8.8+). + // Resolve via `referencedDeclaration` against the build-wide UDVT 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 = udvtIndex.get(refId); + if (underlying !== undefined) { + return encodeMemberType(underlying, udvtIndex); + } + } + + // 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; } @@ -173,7 +258,7 @@ export function encodeMemberType(typeName: unknown): string | undefined { } case "ArrayTypeName": { - const base = encodeMemberType(typeName.baseType); + const base = encodeMemberType(typeName.baseType, udvtIndex); if (base === undefined) { return undefined; } diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts index 34ec7c51397..1a66ae7339f 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -9,7 +9,7 @@ import { bytesToUtf8String } from "@nomicfoundation/hardhat-utils/bytes"; import { HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT } from "../../../../types/solidity/solidity-artifacts.js"; -import { extractStructsFromAst } from "./ast-walker.js"; +import { buildUdvtIndex, extractStructsFromAst } from "./ast-walker.js"; import { canonicalizeStructs } from "./canonicalize.js"; import { isPathSelected } from "./glob.js"; @@ -72,6 +72,17 @@ export function collectEip712CanonicalTypes( continue; } + // Build the UDVT index *per build info*. solc node ids are unique only + // within a single compilation, so pooling the indexes across build infos + // would let one compilation's UDVT shadow another at the same numeric + // id and silently resolve `referencedDeclaration` to the wrong + // underlying type. The index still has to span every source in *this* + // build (not just include-matched ones) because a struct in an included + // file may reference a UDVT defined in a non-included file. + const udvtIndex = buildUdvtIndex( + Object.values(sources).map((s) => s.ast), + ); + for (const [inputSourceName, source] of Object.entries(sources)) { let userSourceName = inputToUserSource.get(inputSourceName); @@ -87,7 +98,9 @@ export function collectEip712CanonicalTypes( continue; } - collected.push(...extractStructsFromAst(source.ast, userSourceName)); + collected.push( + ...extractStructsFromAst(source.ast, userSourceName, udvtIndex), + ); } } diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index 0870d6a7d6a..a11cb7d4275 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { + buildUdvtIndex, encodeMemberType, extractStructsFromAst, } from "../../../../../src/internal/builtin-plugins/solidity-test/eip712/ast-walker.js"; @@ -268,10 +269,49 @@ describe("eip712 - ast-walker", () => { ); }); - it("falls back to typeName.name for user-defined value types", () => { - // User-defined value types (`type MyUint is uint256;`, solc 0.8.8+) emit a typeString that - // doesn't match enum/contract/interface/struct, so we fall back to the - // node's local `name`. + it("resolves user-defined value types to their underlying elementary type", () => { + // `type MyUint is uint256;` should encode as `uint256` + const udvtIndex = new Map>([ + [42, { nodeType: "ElementaryTypeName", name: "uint256" }], + ]); + + assert.equal( + encodeMemberType( + { + nodeType: "UserDefinedTypeName", + name: "MyUint", + referencedDeclaration: 42, + typeDescriptions: { typeString: "MyUint" }, + }, + udvtIndex, + ), + "uint256", + ); + }); + + it("resolves UDVTs via pathNode.referencedDeclaration too", () => { + // Newer solc emits the reference id on `pathNode` rather than the + // top-level node. + const udvtIndex = new Map>([ + [99, { nodeType: "ElementaryTypeName", name: "bytes32" }], + ]); + + assert.equal( + encodeMemberType( + { + nodeType: "UserDefinedTypeName", + typeDescriptions: { typeString: "MyLib.MyHash" }, + pathNode: { name: "MyLib.MyHash", referencedDeclaration: 99 }, + }, + udvtIndex, + ), + "bytes32", + ); + }); + + it("falls back to typeName.name when the UDVT reference can't be resolved", () => { + // No `referencedDeclaration`, or the id isn't in the index — emit the + // alias name so the downstream encoder produces a clear error. assert.equal( encodeMemberType({ nodeType: "UserDefinedTypeName", @@ -366,6 +406,30 @@ describe("eip712 - ast-walker", () => { "uint256[]", ); }); + + it("forwards the UDVT index through array recursion", () => { + // `MyHash[]` where `type MyHash is bytes32` should encode as `bytes32[]`. + const udvtIndex = new Map>([ + [7, { nodeType: "ElementaryTypeName", name: "bytes32" }], + ]); + + assert.equal( + encodeMemberType( + { + nodeType: "ArrayTypeName", + baseType: { + nodeType: "UserDefinedTypeName", + name: "MyHash", + referencedDeclaration: 7, + typeDescriptions: { typeString: "MyHash" }, + }, + length: null, + }, + udvtIndex, + ), + "bytes32[]", + ); + }); }); describe("Mapping", () => { @@ -399,4 +463,167 @@ describe("eip712 - ast-walker", () => { }); }); }); + + describe("buildUdvtIndex", () => { + it("indexes file-level UDVT definitions by node id", () => { + const ast = { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "UserDefinedValueTypeDefinition", + id: 11, + name: "MyUint", + underlyingType: { nodeType: "ElementaryTypeName", name: "uint256" }, + }, + ], + }; + + const index = buildUdvtIndex([ast]); + + assert.equal(index.size, 1); + assert.deepEqual(index.get(11), { + nodeType: "ElementaryTypeName", + name: "uint256", + }); + }); + + it("indexes UDVTs nested inside contracts", () => { + const ast = { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "ContractDefinition", + nodes: [ + { + nodeType: "UserDefinedValueTypeDefinition", + id: 22, + name: "MyHash", + underlyingType: { + nodeType: "ElementaryTypeName", + name: "bytes32", + }, + }, + ], + }, + ], + }; + + const index = buildUdvtIndex([ast]); + + assert.deepEqual(index.get(22), { + nodeType: "ElementaryTypeName", + name: "bytes32", + }); + }); + + it("merges definitions from multiple ASTs", () => { + const astA = { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "UserDefinedValueTypeDefinition", + id: 1, + underlyingType: { nodeType: "ElementaryTypeName", name: "uint256" }, + }, + ], + }; + const astB = { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "UserDefinedValueTypeDefinition", + id: 2, + underlyingType: { nodeType: "ElementaryTypeName", name: "address" }, + }, + ], + }; + + const index = buildUdvtIndex([astA, astB]); + + assert.equal(index.size, 2); + assert.deepEqual(index.get(1), { + nodeType: "ElementaryTypeName", + name: "uint256", + }); + assert.deepEqual(index.get(2), { + nodeType: "ElementaryTypeName", + name: "address", + }); + }); + + it("ignores definitions whose underlyingType is not an ElementaryTypeName", () => { + const ast = { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "UserDefinedValueTypeDefinition", + id: 33, + // Not actually possible in Solidity, but the walker should be + // defensive about malformed/foreign-source ASTs. + underlyingType: { nodeType: "UserDefinedTypeName" }, + }, + ], + }; + + assert.equal(buildUdvtIndex([ast]).size, 0); + }); + + it("ignores non-SourceUnit roots", () => { + assert.equal(buildUdvtIndex([{ nodeType: "Other" }, null, 42]).size, 0); + }); + }); + + describe("extractStructsFromAst with UDVT index", () => { + it("resolves a struct member whose type is a UDVT to the underlying type", () => { + // `type Bytes32 is bytes32; struct Foo { Bytes32 h; }` — the EIP-712 + // string must be `Foo(bytes32 h)`, not `Foo(Bytes32 h)`. + const udvtAst = { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "UserDefinedValueTypeDefinition", + id: 100, + name: "Bytes32", + underlyingType: { + nodeType: "ElementaryTypeName", + name: "bytes32", + }, + }, + ], + }; + + const structAst = { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "StructDefinition", + name: "Foo", + members: [ + { + nodeType: "VariableDeclaration", + name: "h", + typeName: { + nodeType: "UserDefinedTypeName", + name: "Bytes32", + referencedDeclaration: 100, + typeDescriptions: { typeString: "Bytes32" }, + }, + }, + ], + }, + ], + }; + + const udvtIndex = buildUdvtIndex([udvtAst, structAst]); + const out = extractStructsFromAst(structAst, "Foo.sol", udvtIndex); + + assert.deepEqual(out, [ + { + name: "Foo", + sourcePath: "Foo.sol", + members: [{ name: "h", type: "bytes32" }], + }, + ]); + }); + }); }); diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts index 44b9932c8c0..3a344e6f441 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -442,6 +442,104 @@ describe("eip712 - collectEip712CanonicalTypes", () => { assert.deepEqual(result, ["S(uint256 a)"]); }); + it("scopes UDVT resolution per build info when node ids collide", () => { + // solc node ids are unique only within a single compilation. When two + // build infos happen to assign the same numeric id to different UDVTs, + // the collector must resolve each struct's `referencedDeclaration` + // against its own compilation's index — not a pooled one — or it will + // silently emit the wrong underlying type. This is real-world reachable + // because the solidity-test task always passes the union of the + // `contracts` and `tests` artifact dirs into the collector. + const sharedId = 5; + + const buildA = makeBuildInfo("solc-0_8_23-aaaa1111", [ + { + inputSourceName: "project/contracts/A.sol", + userSourceName: "contracts/A.sol", + ast: { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "UserDefinedValueTypeDefinition", + id: sharedId, + name: "Foo", + underlyingType: { + nodeType: "ElementaryTypeName", + name: "uint256", + }, + }, + { + nodeType: "StructDefinition", + name: "AStruct", + members: [ + { + nodeType: "VariableDeclaration", + name: "f", + typeName: { + nodeType: "UserDefinedTypeName", + name: "Foo", + referencedDeclaration: sharedId, + typeDescriptions: { typeString: "Foo" }, + }, + }, + ], + }, + ], + }, + }, + ]); + + const buildB = makeBuildInfo("solc-0_8_23-bbbb2222", [ + { + inputSourceName: "project/test/B.t.sol", + userSourceName: "test/B.t.sol", + ast: { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "UserDefinedValueTypeDefinition", + id: sharedId, + name: "Bar", + underlyingType: { + nodeType: "ElementaryTypeName", + name: "bytes32", + }, + }, + { + nodeType: "StructDefinition", + name: "BStruct", + members: [ + { + nodeType: "VariableDeclaration", + name: "b", + typeName: { + nodeType: "UserDefinedTypeName", + name: "Bar", + referencedDeclaration: sharedId, + typeDescriptions: { typeString: "Bar" }, + }, + }, + ], + }, + ], + }, + }, + ]); + + // Both orderings must produce the same answer — iteration order over + // `buildInfosAndOutputs` is not something callers can control. + const expected = ["AStruct(uint256 f)", "BStruct(bytes32 b)"]; + + assert.deepEqual( + collectEip712CanonicalTypes([buildA, buildB], { include: ["**"] }).sort(), + expected, + ); + assert.deepEqual( + collectEip712CanonicalTypes([buildB, buildA], { include: ["**"] }).sort(), + expected, + ); + }); + it("skips build infos whose output has no sources", () => { // Defensive: a build info output without a `sources` key must be // silently skipped, and structs from sibling build infos must still From ab493ab56bc80fdca800fd686cf8c4d8e3aa082f Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Tue, 12 May 2026 10:14:47 +0000 Subject: [PATCH 12/34] support `supports {a,b} and [abc]` in glob --- .../solidity-test/eip712/glob.ts | 158 +++++++++++++++-- .../solidity-test/eip712/glob.ts | 159 ++++++++++++++++++ 2 files changed, 306 insertions(+), 11 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts index faf828e16c2..cfcfce8b13e 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts @@ -40,22 +40,33 @@ function matchesAny(value: string, patterns: string[]): boolean { } /** - * Compiles a glob pattern into a regular expression. Supports: - * - `*` : zero or more non-slash characters - * - `**` : zero or more characters (including slashes) - * - `?` : exactly one non-slash character - * All other regex metacharacters are escaped. Slashes are matched literally. + * Compiles a glob pattern into a regular expression. Supports `*`, `**`, + * `?`, `[abc]` (including `[a-z]` ranges and `[!abc]` / `[^abc]` negation), + * and `{a,b,c}` alternation. */ function globToRegExp(pattern: string): RegExp { - let regex = "^"; + return new RegExp(`^${translateGlob(pattern)}$`); +} + +function translateGlob(pattern: string): string { + let regex = ""; let i = 0; while (i < pattern.length) { const c = pattern[i]; if (c === "*") { if (pattern[i + 1] === "*") { - regex += ".*"; - i += 2; + // `**` as a full path segment matches zero or more directories, + // e.g. `**/x.sol` matches both `x.sol` and `a/b/x.sol`. + const afterSlash = i === 0 || pattern[i - 1] === "/"; + const beforeSlash = pattern[i + 2] === "/"; + if (afterSlash && beforeSlash) { + regex += "(?:.*/)?"; + i += 3; + } else { + regex += ".*"; + i += 2; + } } else { regex += "[^/]*"; i += 1; @@ -63,7 +74,32 @@ function globToRegExp(pattern: string): RegExp { } else if (c === "?") { regex += "[^/]"; i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { + } else if (c === "[") { + const end = findCharClassEnd(pattern, i); + if (end !== -1) { + regex += translateCharClass(pattern.slice(i + 1, end)); + i = end + 1; + continue; + } + + regex += "\\["; + i += 1; + } else if (c === "{") { + const end = findBraceEnd(pattern, i); + if (end !== -1) { + const alternatives = splitBraceAlternatives( + pattern.slice(i + 1, end), + ).map(translateGlob); + + regex += `(?:${alternatives.join("|")})`; + i = end + 1; + + continue; + } + + regex += "\\{"; + i += 1; + } else if (/[.+^$()|\\\]}]/.test(c)) { regex += `\\${c}`; i += 1; } else { @@ -72,9 +108,109 @@ function globToRegExp(pattern: string): RegExp { } } - regex += "$"; + return regex; +} + +/** + * Returns the index of the `]` that closes the character class opened at + * `start`, or -1 if none is found. E.g. for `a[bc]d` starting at 1, returns 4. + */ +function findCharClassEnd(pattern: string, start: number): number { + for (let i = start + 1; i < pattern.length; i++) { + if (pattern[i] === "]") { + return i; + } + } + + return -1; +} + +/** + * Returns the index of the `}` that closes the brace group opened at `start`, + * handling nested groups and skipping character classes, or -1 if unterminated. + * E.g. `{a,{b,c}}` returns the outer `}`; `{a[}]b}` skips the bracketed `}`. + */ +function findBraceEnd(pattern: string, start: number): number { + let depth = 1; + let i = start + 1; + while (i < pattern.length) { + const c = pattern[i]; + if (c === "{") { + depth += 1; + } else if (c === "}") { + depth -= 1; + if (depth === 0) { + return i; + } + } else if (c === "[") { + const end = findCharClassEnd(pattern, i); + if (end !== -1) { + i = end; + } + } + + i += 1; + } + + return -1; +} + +/** + * Splits a brace group's body on top-level commas, leaving commas inside + * nested braces or character classes untouched. E.g. `a,{b,c},[d,e]` → + * `["a", "{b,c}", "[d,e]"]`. + */ +function splitBraceAlternatives(inside: string): string[] { + const result: string[] = []; + let depth = 0; + let start = 0; + let i = 0; + while (i < inside.length) { + const c = inside[i]; + + if (c === "{") { + depth += 1; + } else if (c === "}") { + depth -= 1; + } else if (c === "[") { + const end = findCharClassEnd(inside, i); + if (end !== -1) { + i = end; + } + } else if (c === "," && depth === 0) { + result.push(inside.slice(start, i)); + start = i + 1; + } + + i += 1; + } + + result.push(inside.slice(start)); + return result; +} + +/** + * Translates a glob character class body into a regex character class. + * E.g. `Mm` → `[Mm]`, `!ab` → `[^ab]`, `a-z` → `[a-z]`. + */ +function translateCharClass(content: string): string { + let negated = false; + let body = content; + if (body.startsWith("!") || body.startsWith("^")) { + negated = true; + body = body.slice(1); + } + + let escaped = ""; + for (const ch of body) { + if (ch === "\\" || ch === "]") { + escaped += `\\${ch}`; + } else { + escaped += ch; + } + } - return new RegExp(regex); + return `[${negated ? "^" : ""}${escaped}]`; } function getCompiledGlob(pattern: string): RegExp { diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts index 1777229c0fb..14294d82e5a 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts @@ -147,5 +147,164 @@ describe("eip712 - glob", () => { true, ); }); + + describe("brace expansion `{a,b}`", () => { + it("matches any of the comma-separated alternatives", () => { + const include = ["contracts/{tokens,vaults}/**/*.sol"]; + + assert.equal( + isPathSelected("contracts/tokens/Foo.sol", include, []), + true, + ); + assert.equal( + isPathSelected("contracts/vaults/sub/Foo.sol", include, []), + true, + ); + assert.equal( + isPathSelected("contracts/other/Foo.sol", include, []), + false, + ); + }); + + it("supports three or more alternatives", () => { + const include = ["src/{a,b,c}.sol"]; + + assert.equal(isPathSelected("src/a.sol", include, []), true); + assert.equal(isPathSelected("src/b.sol", include, []), true); + assert.equal(isPathSelected("src/c.sol", include, []), true); + assert.equal(isPathSelected("src/d.sol", include, []), false); + }); + + it("supports empty alternatives", () => { + const include = ["src/foo{,s}.sol"]; + + assert.equal(isPathSelected("src/foo.sol", include, []), true); + assert.equal(isPathSelected("src/foos.sol", include, []), true); + assert.equal(isPathSelected("src/food.sol", include, []), false); + }); + + it("supports nested brace groups", () => { + const include = ["src/{a,{b,c}}.sol"]; + + assert.equal(isPathSelected("src/a.sol", include, []), true); + assert.equal(isPathSelected("src/b.sol", include, []), true); + assert.equal(isPathSelected("src/c.sol", include, []), true); + assert.equal(isPathSelected("src/d.sol", include, []), false); + }); + + it("allows inner glob syntax within alternatives", () => { + const include = ["{contracts/**/*.sol,tests/*.sol}"]; + + assert.equal( + isPathSelected("contracts/a/b/Foo.sol", include, []), + true, + ); + assert.equal(isPathSelected("tests/Foo.sol", include, []), true); + assert.equal(isPathSelected("tests/sub/Foo.sol", include, []), false); + }); + + it("works in exclude patterns", () => { + assert.equal( + isPathSelected( + "contracts/mocks/Foo.sol", + ["**"], + ["contracts/{mocks,tests}/**"], + ), + false, + ); + assert.equal( + isPathSelected( + "contracts/tests/Foo.sol", + ["**"], + ["contracts/{mocks,tests}/**"], + ), + false, + ); + assert.equal( + isPathSelected( + "contracts/src/Foo.sol", + ["**"], + ["contracts/{mocks,tests}/**"], + ), + true, + ); + }); + + it("treats an unterminated `{` as a literal character", () => { + assert.equal(isPathSelected("a{b.sol", ["a{b.sol"], []), true); + assert.equal(isPathSelected("ab.sol", ["a{b.sol"], []), false); + }); + }); + + describe("character classes `[abc]`", () => { + it("matches any single character from the set", () => { + const exclude = ["contracts/[Mm]ock*.sol"]; + + assert.equal( + isPathSelected("contracts/Mock.sol", ["**"], exclude), + false, + ); + assert.equal( + isPathSelected("contracts/mock.sol", ["**"], exclude), + false, + ); + assert.equal( + isPathSelected("contracts/Mocks.sol", ["**"], exclude), + false, + ); + assert.equal( + isPathSelected("contracts/Tock.sol", ["**"], exclude), + true, + ); + }); + + it("matches exactly one character", () => { + const include = ["src/[ab].sol"]; + + assert.equal(isPathSelected("src/a.sol", include, []), true); + assert.equal(isPathSelected("src/ab.sol", include, []), false); + assert.equal(isPathSelected("src/.sol", include, []), false); + }); + + it("supports ranges", () => { + const include = ["src/[a-c].sol"]; + + assert.equal(isPathSelected("src/a.sol", include, []), true); + assert.equal(isPathSelected("src/b.sol", include, []), true); + assert.equal(isPathSelected("src/c.sol", include, []), true); + assert.equal(isPathSelected("src/d.sol", include, []), false); + }); + + it("supports negation with `!`", () => { + const include = ["src/[!ab].sol"]; + + assert.equal(isPathSelected("src/a.sol", include, []), false); + assert.equal(isPathSelected("src/b.sol", include, []), false); + assert.equal(isPathSelected("src/c.sol", include, []), true); + }); + + it("supports negation with `^`", () => { + const include = ["src/[^ab].sol"]; + + assert.equal(isPathSelected("src/a.sol", include, []), false); + assert.equal(isPathSelected("src/b.sol", include, []), false); + assert.equal(isPathSelected("src/c.sol", include, []), true); + }); + + it("treats an unterminated `[` as a literal character", () => { + assert.equal(isPathSelected("a[b.sol", ["a[b.sol"], []), true); + assert.equal(isPathSelected("ab.sol", ["a[b.sol"], []), false); + }); + + it("works inside brace alternatives", () => { + const include = ["src/{[Aa],[Bb]}.sol"]; + + assert.equal(isPathSelected("src/A.sol", include, []), true); + assert.equal(isPathSelected("src/a.sol", include, []), true); + assert.equal(isPathSelected("src/B.sol", include, []), true); + assert.equal(isPathSelected("src/b.sol", include, []), true); + assert.equal(isPathSelected("src/c.sol", include, []), false); + }); + }); }); }); From 370a227552cac11c4d82c53890646e6a87f19b11 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Tue, 12 May 2026 10:42:45 +0000 Subject: [PATCH 13/34] lint:fix --- .../internal/builtin-plugins/solidity-test/eip712/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts index 1a66ae7339f..3c49da3982f 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -79,9 +79,7 @@ export function collectEip712CanonicalTypes( // underlying type. The index still has to span every source in *this* // build (not just include-matched ones) because a struct in an included // file may reference a UDVT defined in a non-included file. - const udvtIndex = buildUdvtIndex( - Object.values(sources).map((s) => s.ast), - ); + const udvtIndex = buildUdvtIndex(Object.values(sources).map((s) => s.ast)); for (const [inputSourceName, source] of Object.entries(sources)) { let userSourceName = inputToUserSource.get(inputSourceName); From f2cdddda6b37a4c590309ac01c078492d54d6a98 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Tue, 12 May 2026 10:43:18 +0000 Subject: [PATCH 14/34] split changeset file --- .changeset/clever-foxes-jump.md | 6 ++++++ .changeset/good-islands-hide.md | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/clever-foxes-jump.md diff --git a/.changeset/clever-foxes-jump.md b/.changeset/clever-foxes-jump.md new file mode 100644 index 00000000000..eb77b370ac3 --- /dev/null +++ b/.changeset/clever-foxes-jump.md @@ -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. diff --git a/.changeset/good-islands-hide.md b/.changeset/good-islands-hide.md index b08d0fae46f..36fb8811123 100644 --- a/.changeset/good-islands-hide.md +++ b/.changeset/good-islands-hide.md @@ -1,6 +1,5 @@ --- # docs: https://github.com/NomicFoundation/hardhat-website/pull/260 -"@nomicfoundation/hardhat-errors": patch "hardhat": patch --- From 4fecd9ebb813f0f646b1fa1d6ea591dd08529fe6 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Tue, 12 May 2026 10:52:43 +0000 Subject: [PATCH 15/34] fix grammar errors --- .../solidity-test/eip712/ast-walker.ts | 54 +++++++++++------- .../solidity-test/eip712/index.ts | 30 ++++++---- .../solidity-test/eip712/ast-walker.ts | 56 +++++++++++-------- .../solidity-test/eip712/glob.ts | 10 ++-- .../solidity-test/eip712/index.ts | 5 +- 5 files changed, 94 insertions(+), 61 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index cc219479a0d..61bef744487 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -26,21 +26,25 @@ export interface CollectedStruct { /** * 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 UDVT definition's solc node id, which is what - * `referencedDeclaration` on a `UserDefinedTypeName` reference points to. + * 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 UdvtIndex = Map>; +export type UserDefinedValueTypeIndex = Map>; /** * Walks every AST in the build and indexes every `UserDefinedValueTypeDefinition` * by its node `id`, mapping to its `underlyingType` (an `ElementaryTypeName`). * - * UDVTs can sit at file scope or inside a contract, and a struct in one source - * may reference a UDVT defined in another, so this must run across every AST in - * the build — not just the ones matched by the user's include globs. + * 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 buildUdvtIndex(asts: unknown[]): UdvtIndex { - const index: UdvtIndex = new Map(); +export function buildUserDefinedValueTypeIndex( + asts: unknown[], +): UserDefinedValueTypeIndex { + const index: UserDefinedValueTypeIndex = new Map(); for (const ast of asts) { if (!isObject(ast) || ast.nodeType !== "SourceUnit") { @@ -54,7 +58,7 @@ export function buildUdvtIndex(asts: unknown[]): UdvtIndex { } if (node.nodeType === "UserDefinedValueTypeDefinition") { - recordUdvt(node, index); + recordUserDefinedValueType(node, index); } else if (node.nodeType === "ContractDefinition") { const members: unknown[] = Array.isArray(node.nodes) ? node.nodes : []; for (const member of members) { @@ -62,7 +66,7 @@ export function buildUdvtIndex(asts: unknown[]): UdvtIndex { isObject(member) && member.nodeType === "UserDefinedValueTypeDefinition" ) { - recordUdvt(member, index); + recordUserDefinedValueType(member, index); } } } @@ -72,7 +76,10 @@ export function buildUdvtIndex(asts: unknown[]): UdvtIndex { return index; } -function recordUdvt(node: Record, index: UdvtIndex): void { +function recordUserDefinedValueType( + node: Record, + index: UserDefinedValueTypeIndex, +): void { if ( typeof node.id === "number" && isObject(node.underlyingType) && @@ -89,7 +96,7 @@ function recordUdvt(node: Record, index: UdvtIndex): void { export function extractStructsFromAst( ast: unknown, sourcePath: string, - udvtIndex: UdvtIndex = new Map(), + userDefinedValueTypeI: UserDefinedValueTypeIndex = new Map(), ): CollectedStruct[] { if (!isObject(ast) || ast.nodeType !== "SourceUnit") { return []; @@ -104,7 +111,7 @@ export function extractStructsFromAst( } if (node.nodeType === "StructDefinition") { - const collected = collectStruct(node, sourcePath, udvtIndex); + const collected = collectStruct(node, sourcePath, userDefinedValueTypeI); if (collected !== undefined) { results.push(collected); } @@ -112,7 +119,11 @@ export function extractStructsFromAst( 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, udvtIndex); + const collected = collectStruct( + member, + sourcePath, + userDefinedValueTypeI, + ); if (collected !== undefined) { results.push(collected); } @@ -127,7 +138,7 @@ export function extractStructsFromAst( function collectStruct( node: Record, sourcePath: string, - udvtIndex: UdvtIndex, + userDefinedValueTypeI: UserDefinedValueTypeIndex, ): CollectedStruct | undefined { if (typeof node.name !== "string") { return undefined; @@ -152,7 +163,7 @@ function collectStruct( members.push({ name: memberNode.name, - type: encodeMemberType(memberNode.typeName, udvtIndex), + type: encodeMemberType(memberNode.typeName, userDefinedValueTypeI), }); } @@ -177,7 +188,7 @@ function collectStruct( */ export function encodeMemberType( typeName: unknown, - udvtIndex: UdvtIndex = new Map(), + userDefinedValueTypeI: UserDefinedValueTypeIndex = new Map(), ): string | undefined { if (!isObject(typeName)) { return undefined; @@ -220,7 +231,8 @@ export function encodeMemberType( } // User-defined value types (`type Foo is bytes32;`, solc 0.8.8+). - // Resolve via `referencedDeclaration` against the build-wide UDVT index + // 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 = @@ -232,9 +244,9 @@ export function encodeMemberType( : undefined; if (refId !== undefined) { - const underlying = udvtIndex.get(refId); + const underlying = userDefinedValueTypeI.get(refId); if (underlying !== undefined) { - return encodeMemberType(underlying, udvtIndex); + return encodeMemberType(underlying, userDefinedValueTypeI); } } @@ -258,7 +270,7 @@ export function encodeMemberType( } case "ArrayTypeName": { - const base = encodeMemberType(typeName.baseType, udvtIndex); + const base = encodeMemberType(typeName.baseType, userDefinedValueTypeI); if (base === undefined) { return undefined; } diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts index 3c49da3982f..4394287af93 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -9,7 +9,10 @@ import { bytesToUtf8String } from "@nomicfoundation/hardhat-utils/bytes"; import { HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT } from "../../../../types/solidity/solidity-artifacts.js"; -import { buildUdvtIndex, extractStructsFromAst } from "./ast-walker.js"; +import { + buildUserDefinedValueTypeIndex, + extractStructsFromAst, +} from "./ast-walker.js"; import { canonicalizeStructs } from "./canonicalize.js"; import { isPathSelected } from "./glob.js"; @@ -72,14 +75,17 @@ export function collectEip712CanonicalTypes( continue; } - // Build the UDVT index *per build info*. solc node ids are unique only - // within a single compilation, so pooling the indexes across build infos - // would let one compilation's UDVT shadow another at the same numeric - // id and silently resolve `referencedDeclaration` to the wrong - // underlying type. The index still has to span every source in *this* - // build (not just include-matched ones) because a struct in an included - // file may reference a UDVT defined in a non-included file. - const udvtIndex = buildUdvtIndex(Object.values(sources).map((s) => s.ast)); + // Build the user-defined value type index *per build info*. solc node ids + // are unique only within a single compilation, so pooling the indexes + // across build infos would let one compilation's user-defined value type + // shadow another at the same numeric id and silently resolve + // `referencedDeclaration` to the wrong underlying type. The index still + // has to span every source in *this* build (not just include-matched ones) + // because a struct in an included file may reference a user-defined value + // type defined in a non-included file. + const userDefinedValueTypeI = buildUserDefinedValueTypeIndex( + Object.values(sources).map((s) => s.ast), + ); for (const [inputSourceName, source] of Object.entries(sources)) { let userSourceName = inputToUserSource.get(inputSourceName); @@ -97,7 +103,11 @@ export function collectEip712CanonicalTypes( } collected.push( - ...extractStructsFromAst(source.ast, userSourceName, udvtIndex), + ...extractStructsFromAst( + source.ast, + userSourceName, + userDefinedValueTypeI, + ), ); } } diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index a11cb7d4275..f865a667f83 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { - buildUdvtIndex, + buildUserDefinedValueTypeIndex, encodeMemberType, extractStructsFromAst, } from "../../../../../src/internal/builtin-plugins/solidity-test/eip712/ast-walker.js"; @@ -271,7 +271,7 @@ describe("eip712 - ast-walker", () => { it("resolves user-defined value types to their underlying elementary type", () => { // `type MyUint is uint256;` should encode as `uint256` - const udvtIndex = new Map>([ + const userDefinedValueTypeI = new Map>([ [42, { nodeType: "ElementaryTypeName", name: "uint256" }], ]); @@ -283,16 +283,16 @@ describe("eip712 - ast-walker", () => { referencedDeclaration: 42, typeDescriptions: { typeString: "MyUint" }, }, - udvtIndex, + userDefinedValueTypeI, ), "uint256", ); }); - it("resolves UDVTs via pathNode.referencedDeclaration too", () => { + it("resolves user-defined value types via pathNode.referencedDeclaration too", () => { // Newer solc emits the reference id on `pathNode` rather than the // top-level node. - const udvtIndex = new Map>([ + const userDefinedValueTypeI = new Map>([ [99, { nodeType: "ElementaryTypeName", name: "bytes32" }], ]); @@ -303,13 +303,13 @@ describe("eip712 - ast-walker", () => { typeDescriptions: { typeString: "MyLib.MyHash" }, pathNode: { name: "MyLib.MyHash", referencedDeclaration: 99 }, }, - udvtIndex, + userDefinedValueTypeI, ), "bytes32", ); }); - it("falls back to typeName.name when the UDVT reference can't be resolved", () => { + it("falls back to typeName.name when the user-defined value type reference can't be resolved", () => { // No `referencedDeclaration`, or the id isn't in the index — emit the // alias name so the downstream encoder produces a clear error. assert.equal( @@ -407,9 +407,9 @@ describe("eip712 - ast-walker", () => { ); }); - it("forwards the UDVT index through array recursion", () => { + it("forwards the user-defined value type index through array recursion", () => { // `MyHash[]` where `type MyHash is bytes32` should encode as `bytes32[]`. - const udvtIndex = new Map>([ + const userDefinedValueTypeI = new Map>([ [7, { nodeType: "ElementaryTypeName", name: "bytes32" }], ]); @@ -425,7 +425,7 @@ describe("eip712 - ast-walker", () => { }, length: null, }, - udvtIndex, + userDefinedValueTypeI, ), "bytes32[]", ); @@ -464,8 +464,8 @@ describe("eip712 - ast-walker", () => { }); }); - describe("buildUdvtIndex", () => { - it("indexes file-level UDVT definitions by node id", () => { + describe("buildUserDefinedValueTypeIndex", () => { + it("indexes file-level user-defined value type definitions by node id", () => { const ast = { nodeType: "SourceUnit", nodes: [ @@ -478,7 +478,7 @@ describe("eip712 - ast-walker", () => { ], }; - const index = buildUdvtIndex([ast]); + const index = buildUserDefinedValueTypeIndex([ast]); assert.equal(index.size, 1); assert.deepEqual(index.get(11), { @@ -487,7 +487,7 @@ describe("eip712 - ast-walker", () => { }); }); - it("indexes UDVTs nested inside contracts", () => { + it("indexes user-defined value types nested inside contracts", () => { const ast = { nodeType: "SourceUnit", nodes: [ @@ -508,7 +508,7 @@ describe("eip712 - ast-walker", () => { ], }; - const index = buildUdvtIndex([ast]); + const index = buildUserDefinedValueTypeIndex([ast]); assert.deepEqual(index.get(22), { nodeType: "ElementaryTypeName", @@ -538,7 +538,7 @@ describe("eip712 - ast-walker", () => { ], }; - const index = buildUdvtIndex([astA, astB]); + const index = buildUserDefinedValueTypeIndex([astA, astB]); assert.equal(index.size, 2); assert.deepEqual(index.get(1), { @@ -565,19 +565,22 @@ describe("eip712 - ast-walker", () => { ], }; - assert.equal(buildUdvtIndex([ast]).size, 0); + assert.equal(buildUserDefinedValueTypeIndex([ast]).size, 0); }); it("ignores non-SourceUnit roots", () => { - assert.equal(buildUdvtIndex([{ nodeType: "Other" }, null, 42]).size, 0); + assert.equal( + buildUserDefinedValueTypeIndex([{ nodeType: "Other" }, null, 42]).size, + 0, + ); }); }); - describe("extractStructsFromAst with UDVT index", () => { - it("resolves a struct member whose type is a UDVT to the underlying type", () => { + describe("extractStructsFromAst with user-defined value type index", () => { + it("resolves a struct member whose type is a user-defined value type to the underlying type", () => { // `type Bytes32 is bytes32; struct Foo { Bytes32 h; }` — the EIP-712 // string must be `Foo(bytes32 h)`, not `Foo(Bytes32 h)`. - const udvtAst = { + const userDefinedValueTypeAst = { nodeType: "SourceUnit", nodes: [ { @@ -614,8 +617,15 @@ describe("eip712 - ast-walker", () => { ], }; - const udvtIndex = buildUdvtIndex([udvtAst, structAst]); - const out = extractStructsFromAst(structAst, "Foo.sol", udvtIndex); + const userDefinedValueTypeI = buildUserDefinedValueTypeIndex([ + userDefinedValueTypeAst, + structAst, + ]); + const out = extractStructsFromAst( + structAst, + "Foo.sol", + userDefinedValueTypeI, + ); assert.deepEqual(out, [ { diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts index 14294d82e5a..2ee164fc649 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts @@ -176,11 +176,11 @@ describe("eip712 - glob", () => { }); it("supports empty alternatives", () => { - const include = ["src/foo{,s}.sol"]; + const include = ["src/cat{,s}.sol"]; - assert.equal(isPathSelected("src/foo.sol", include, []), true); - assert.equal(isPathSelected("src/foos.sol", include, []), true); - assert.equal(isPathSelected("src/food.sol", include, []), false); + assert.equal(isPathSelected("src/cat.sol", include, []), true); + assert.equal(isPathSelected("src/cats.sol", include, []), true); + assert.equal(isPathSelected("src/cars.sol", include, []), false); }); it("supports nested brace groups", () => { @@ -253,7 +253,7 @@ describe("eip712 - glob", () => { false, ); assert.equal( - isPathSelected("contracts/Tock.sol", ["**"], exclude), + isPathSelected("contracts/Lock.sol", ["**"], exclude), true, ); }); diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts index 3a344e6f441..59f457d6259 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -442,9 +442,10 @@ describe("eip712 - collectEip712CanonicalTypes", () => { assert.deepEqual(result, ["S(uint256 a)"]); }); - it("scopes UDVT resolution per build info when node ids collide", () => { + it("scopes user-defined value type resolution per build info when node ids collide", () => { // solc node ids are unique only within a single compilation. When two - // build infos happen to assign the same numeric id to different UDVTs, + // build infos happen to assign the same numeric id to different + // user-defined value types, // the collector must resolve each struct's `referencedDeclaration` // against its own compilation's index — not a pooled one — or it will // silently emit the wrong underlying type. This is real-world reachable From c7812618c0e7672136eee03135912a9c09668071 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Tue, 12 May 2026 14:44:08 +0000 Subject: [PATCH 16/34] fix glob bug --- .../solidity-test/eip712/glob.ts | 2 +- .../solidity-test/eip712/glob.ts | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts index cfcfce8b13e..7b448111a82 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/glob.ts @@ -210,7 +210,7 @@ function translateCharClass(content: string): string { } } - return `[${negated ? "^" : ""}${escaped}]`; + return `(?!/)[${negated ? "^" : ""}${escaped}]`; } function getCompiledGlob(pattern: string): RegExp { diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts index 2ee164fc649..cec50973330 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts @@ -296,6 +296,30 @@ describe("eip712 - glob", () => { assert.equal(isPathSelected("ab.sol", ["a[b.sol"], []), false); }); + it("does not match the path separator in a negated class", () => { + assert.equal( + isPathSelected("src/foo/bar.sol", ["src/foo[!x]bar.sol"], []), + false, + ); + assert.equal( + isPathSelected("src/fooybar.sol", ["src/foo[!x]bar.sol"], []), + true, + ); + }); + + it("does not match the path separator when `/` is in a positive class", () => { + // A user-written `/` inside a class should never cross directory + // boundaries, consistent with `*` and `?`. + assert.equal( + isPathSelected("src/foo/bar.sol", ["src/foo[/x]bar.sol"], []), + false, + ); + assert.equal( + isPathSelected("src/fooxbar.sol", ["src/foo[/x]bar.sol"], []), + true, + ); + }); + it("works inside brace alternatives", () => { const include = ["src/{[Aa],[Bb]}.sol"]; From c47fe34eb0d1054604033808c6cd0c7b8cf4bfbd Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Tue, 12 May 2026 15:17:57 +0000 Subject: [PATCH 17/34] cover empty `[]` and `[!]` char classes in glob tests --- .../internal/builtin-plugins/solidity-test/eip712/glob.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts index 2ee164fc649..3ac5aeaef7b 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts @@ -305,6 +305,13 @@ describe("eip712 - glob", () => { assert.equal(isPathSelected("src/b.sol", include, []), true); assert.equal(isPathSelected("src/c.sol", include, []), false); }); + + it("compiles empty and negated-empty character classes without throwing", () => { + assert.equal(isPathSelected("", ["[]"], []), false); + assert.equal(isPathSelected("a", ["[]"], []), false); + assert.equal(isPathSelected("a", ["[!]"], []), true); + assert.equal(isPathSelected("ab", ["[!]"], []), false); + }); }); }); }); From 2933352c3ec2f5607f852db4adb9120fd6e8faba Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Tue, 12 May 2026 15:26:23 +0000 Subject: [PATCH 18/34] fix spelling issues --- .../test/internal/builtin-plugins/solidity-test/eip712/glob.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts index b10bfcf4759..754e96d4515 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/glob.ts @@ -302,6 +302,7 @@ describe("eip712 - glob", () => { false, ); assert.equal( + /* cspell:disable-next-line */ isPathSelected("src/fooybar.sol", ["src/foo[!x]bar.sol"], []), true, ); @@ -315,6 +316,7 @@ describe("eip712 - glob", () => { false, ); assert.equal( + /* cspell:disable-next-line */ isPathSelected("src/fooxbar.sol", ["src/foo[/x]bar.sol"], []), true, ); From a17ad5971155e1fc10bb84c80879e69fc1311a8d Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Tue, 12 May 2026 16:25:21 +0000 Subject: [PATCH 19/34] inline cross-file deps when filtering EIP-712 structs --- .../solidity-test/eip712/canonicalize.ts | 37 +++- .../solidity-test/eip712/index.ts | 35 ++-- .../solidity-test/eip712/canonicalize.ts | 161 +++++++++++++++ .../solidity-test/eip712/index.ts | 192 ++++++++++++++++++ 4 files changed, 406 insertions(+), 19 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts index efe597ada9c..82b3f007d54 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -24,15 +24,25 @@ import { HardhatError } from "@nomicfoundation/hardhat-errors"; * `resolve_struct_eip712` returns `None` for any struct containing unsupported * constructs and propagates `None` through the dep graph so dependents are * also dropped. + * + * When `selectedNames` is provided, only those names are emitted; non-selected + * structs still participate in dep resolution so cross-file deps inline correctly. */ -export function canonicalizeStructs(structs: CollectedStruct[]): string[] { - const byName = indexByName(structs); +export function canonicalizeStructs( + structs: CollectedStruct[], + selectedNames?: Set, +): string[] { + const byName = indexByName(structs, selectedNames); const knownNames = new Set(byName.keys()); const encodable = computeEncodable(byName, knownNames); const seen = new Set(); const result: string[] = []; for (const struct of byName.values()) { + if (selectedNames !== undefined && !selectedNames.has(struct.name)) { + continue; + } + if (!encodable.has(struct.name)) { continue; } @@ -192,13 +202,28 @@ function transitiveDeps( * than collapsed into one. Comparing only the encoded head would let a * non decodable definition silently win over an encodable one, dropping the * struct from the canonical output. + * + * Conflicts confined to non-selected names are silently deduped (first wins); + * conflicts involving a selected name still throw. Selected structs are + * processed first so they win over non-selected copies. */ -function indexByName(structs: CollectedStruct[]): Map { +function indexByName( + structs: CollectedStruct[], + selectedNames?: Set, +): Map { const byName = new Map(); const fingerprintByName = new Map(); const sourceByName = new Map(); - for (const struct of structs) { + const ordered = + selectedNames === undefined + ? structs + : [ + ...structs.filter((s) => selectedNames.has(s.name)), + ...structs.filter((s) => !selectedNames.has(s.name)), + ]; + + for (const struct of ordered) { const fingerprint = fingerprintStruct(struct); const existingFingerprint = fingerprintByName.get(struct.name); @@ -213,6 +238,10 @@ function indexByName(structs: CollectedStruct[]): Map { continue; } + if (selectedNames !== undefined && !selectedNames.has(struct.name)) { + continue; + } + throw new HardhatError( HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, { diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts index 4394287af93..a5b8229a05a 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -28,10 +28,11 @@ export interface Eip712TypesConfig { const PROJECT_INPUT_SOURCE_NAME_PREFIX = `${HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT}/`; /** - * Walks every compiled source's AST whose user source name matches the - * configured `include` globs (and isn't matched by `exclude`), extracts - * every struct definition, and returns the flat list of canonical EIP-712 - * type strings expected by EDR's `eip712CanonicalTypes` config field. + * Walks every compiled source's AST, extracts every struct definition, and + * returns the flat list of canonical EIP-712 type strings expected by EDR's + * `eip712CanonicalTypes` config field. Only structs from sources matching + * `include`/`exclude` are emitted; non-selected sources still feed the dep + * graph so cross-file deps inline correctly. * * When `include` is empty/unset the feature is off: collection short-circuits * and returns an empty list without parsing any build info. @@ -68,6 +69,7 @@ export function collectEip712CanonicalTypes( } const collected: CollectedStruct[] = []; + const selectedNames = new Set(); for (const { output } of parsed) { const sources = output.output.sources; @@ -98,19 +100,22 @@ export function collectEip712CanonicalTypes( : inputSourceName; } - if (!isPathSelected(userSourceName, include, exclude)) { - continue; - } - - collected.push( - ...extractStructsFromAst( - source.ast, - userSourceName, - userDefinedValueTypeI, - ), + // Collect every source so non-selected files can serve as dep targets; + // selection is enforced at emit time via `selectedNames`. + const structs = extractStructsFromAst( + source.ast, + userSourceName, + userDefinedValueTypeI, ); + collected.push(...structs); + + if (isPathSelected(userSourceName, include, exclude)) { + for (const s of structs) { + selectedNames.add(s.name); + } + } } } - return canonicalizeStructs(collected); + return canonicalizeStructs(collected, selectedNames); } diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts index c47f5dd4269..14d78bbca6d 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -314,4 +314,165 @@ describe("eip712 - canonicalize", () => { const result = canonicalizeStructs([struct("S", [["S[]", "children"]])]); assert.deepEqual(result, ["S(S[] children)"]); }); + + describe("selectedNames", () => { + it("only emits selected structs but inline deps from non-selected ones", () => { + const collected = [ + struct( + "Mail", + [ + ["Person", "from"], + ["Person", "to"], + ["string", "contents"], + ], + "test/Mail.sol", + ), + struct( + "Person", + [ + ["address", "wallet"], + ["string", "name"], + ], + "lib/Person.sol", + ), + ]; + + const result = canonicalizeStructs(collected, new Set(["Mail"])); + + assert.deepEqual(result, [ + "Mail(Person from,Person to,string contents)Person(address wallet,string name)", + ]); + }); + + it("walks transitive deps through non-selected structs", () => { + const collected = [ + struct( + "Order", + [ + ["uint256", "id"], + ["Holder", "holder"], + ], + "test/Order.sol", + ), + struct( + "Holder", + [ + ["address", "owner"], + ["Asset", "asset"], + ], + "lib/Holder.sol", + ), + struct( + "Asset", + [ + ["address", "token"], + ["uint256", "amount"], + ], + "lib/Asset.sol", + ), + ]; + + const result = canonicalizeStructs(collected, new Set(["Order"])); + + assert.deepEqual(result, [ + "Order(uint256 id,Holder holder)" + + "Asset(address token,uint256 amount)" + + "Holder(address owner,Asset asset)", + ]); + }); + + it("drops a selected struct when a non-selected transitive dep is not decodable", () => { + const collected = [ + struct( + "Order", + [ + ["uint256", "id"], + ["Holder", "holder"], + ], + "test/Order.sol", + ), + struct( + "Holder", + [ + ["uint256", "amount"], + [undefined, "balances"], + ], + "lib/Holder.sol", + ), + ]; + + const result = canonicalizeStructs(collected, new Set(["Order"])); + + assert.deepEqual(result, []); + }); + + it("does not emit non-selected structs even when they are encodable", () => { + const collected = [ + struct("Foo", [["uint256", "x"]], "test/Foo.sol"), + struct("Bar", [["uint256", "y"]], "lib/Bar.sol"), + ]; + + const result = canonicalizeStructs(collected, new Set(["Foo"])); + + assert.deepEqual(result, ["Foo(uint256 x)"]); + }); + + it("returns empty when selectedNames is empty", () => { + const collected = [ + struct("Foo", [["uint256", "x"]], "test/Foo.sol"), + struct("Bar", [["uint256", "y"]], "lib/Bar.sol"), + ]; + + const result = canonicalizeStructs(collected, new Set()); + + assert.deepEqual(result, []); + }); + + it("does not throw on conflicting definitions when neither side is selected", () => { + const collected = [ + struct("Wanted", [["uint256", "x"]], "test/Wanted.sol"), + struct("Helper", [["uint256", "a"]], "lib/A.sol"), + struct("Helper", [["uint256", "b"]], "lib/B.sol"), + ]; + + const result = canonicalizeStructs(collected, new Set(["Wanted"])); + + assert.deepEqual(result, ["Wanted(uint256 x)"]); + }); + + it("throws when a selected name is also defined differently in a non-selected file", () => { + const collected = [ + struct( + "Person", + [ + ["address", "wallet"], + ["string", "name"], + ], + "test/Person.sol", + ), + struct("Person", [["uint256", "x"]], "lib/Other.sol"), + ]; + + assertThrowsHardhatError( + () => canonicalizeStructs(collected, new Set(["Person"])), + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, + { + name: "Person", + firstSource: "test/Person.sol", + secondSource: "lib/Other.sol", + }, + ); + + assertThrowsHardhatError( + () => + canonicalizeStructs([...collected].reverse(), new Set(["Person"])), + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, + { + name: "Person", + firstSource: "lib/Other.sol", + secondSource: "test/Person.sol", + }, + ); + }); + }); }); diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts index 59f457d6259..36448070e12 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -541,6 +541,198 @@ describe("eip712 - collectEip712CanonicalTypes", () => { ); }); + it("inline a dep defined in a non-included file", () => { + const buildInfo = makeBuildInfo("solc-0_8_23-aabbccdd", [ + { + inputSourceName: "project/test/Mail.sol", + userSourceName: "test/Mail.sol", + ast: sourceUnit([ + structAst("Mail", [ + { type: "Person", name: "from" }, + { type: "Person", name: "to" }, + { type: "string", name: "contents" }, + ]), + ]), + }, + { + inputSourceName: "project/lib/Person.sol", + userSourceName: "lib/Person.sol", + ast: sourceUnit([ + structAst("Person", [ + { type: "address", name: "wallet" }, + { type: "string", name: "name" }, + ]), + ]), + }, + ]); + + const result = collectEip712CanonicalTypes([buildInfo], { + include: ["test/**"], + }); + + assert.deepEqual(result, [ + "Mail(Person from,Person to,string contents)Person(address wallet,string name)", + ]); + }); + + it("inline a dep defined in a different build info", () => { + const testBuild = makeBuildInfo("solc-0_8_23-11112222", [ + { + inputSourceName: "project/test/Mail.sol", + userSourceName: "test/Mail.sol", + ast: sourceUnit([ + structAst("Mail", [ + { type: "Person", name: "from" }, + { type: "string", name: "contents" }, + ]), + ]), + }, + ]); + const libBuild = makeBuildInfo("solc-0_8_23-33334444", [ + { + inputSourceName: "project/lib/Person.sol", + userSourceName: "lib/Person.sol", + ast: sourceUnit([ + structAst("Person", [ + { type: "address", name: "wallet" }, + { type: "string", name: "name" }, + ]), + ]), + }, + ]); + + const result = collectEip712CanonicalTypes([testBuild, libBuild], { + include: ["test/**"], + }); + + assert.deepEqual(result, [ + "Mail(Person from,string contents)Person(address wallet,string name)", + ]); + }); + + it("walks transitive deps through non-included files", () => { + const buildInfo = makeBuildInfo("solc-0_8_23-55556666", [ + { + inputSourceName: "project/test/Order.sol", + userSourceName: "test/Order.sol", + ast: sourceUnit([ + structAst("Order", [ + { type: "uint256", name: "id" }, + { type: "Holder", name: "holder" }, + ]), + ]), + }, + { + inputSourceName: "project/lib/Holder.sol", + userSourceName: "lib/Holder.sol", + ast: sourceUnit([ + structAst("Holder", [ + { type: "address", name: "owner" }, + { type: "Asset", name: "asset" }, + ]), + ]), + }, + { + inputSourceName: "project/lib/Asset.sol", + userSourceName: "lib/Asset.sol", + ast: sourceUnit([ + structAst("Asset", [ + { type: "address", name: "token" }, + { type: "uint256", name: "amount" }, + ]), + ]), + }, + ]); + + const result = collectEip712CanonicalTypes([buildInfo], { + include: ["test/**"], + }); + + assert.deepEqual(result, [ + "Order(uint256 id,Holder holder)" + + "Asset(address token,uint256 amount)" + + "Holder(address owner,Asset asset)", + ]); + }); + + it("does not throw on duplicate struct names confined to non-included files", () => { + const buildInfo = makeBuildInfo("solc-0_8_23-99990000", [ + { + inputSourceName: "project/test/Wanted.sol", + userSourceName: "test/Wanted.sol", + ast: sourceUnit([ + structAst("Wanted", [{ type: "uint256", name: "x" }]), + ]), + }, + { + inputSourceName: "project/lib/A.sol", + userSourceName: "lib/A.sol", + ast: sourceUnit([ + structAst("Helper", [{ type: "uint256", name: "a" }]), + ]), + }, + { + inputSourceName: "project/lib/B.sol", + userSourceName: "lib/B.sol", + ast: sourceUnit([ + structAst("Helper", [{ type: "uint256", name: "b" }]), + ]), + }, + ]); + + const result = collectEip712CanonicalTypes([buildInfo], { + include: ["test/**"], + }); + + assert.deepEqual(result, ["Wanted(uint256 x)"]); + }); + + it("drops a selected struct when a transitive dep in a non-included file is non decodable", () => { + const buildInfo = makeBuildInfo("solc-0_8_23-aaaa9999", [ + { + inputSourceName: "project/test/Order.sol", + userSourceName: "test/Order.sol", + ast: sourceUnit([ + structAst("Order", [ + { type: "uint256", name: "id" }, + { type: "Holder", name: "holder" }, + ]), + ]), + }, + { + inputSourceName: "project/lib/Holder.sol", + userSourceName: "lib/Holder.sol", + ast: { + nodeType: "SourceUnit", + nodes: [ + { + nodeType: "StructDefinition", + name: "Holder", + members: [ + { + nodeType: "VariableDeclaration", + name: "amount", + typeName: { nodeType: "ElementaryTypeName", name: "uint256" }, + }, + { + nodeType: "VariableDeclaration", + name: "balances", + typeName: { nodeType: "Mapping" }, + }, + ], + }, + ], + }, + }, + ]); + + const result = collectEip712CanonicalTypes([buildInfo], { + include: ["test/**"], + }); + + assert.deepEqual(result, []); + }); + it("skips build infos whose output has no sources", () => { // Defensive: a build info output without a `sources` key must be // silently skipped, and structs from sibling build infos must still From e043b885aad33d1405fc39d89ae3ba7651b05c19 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Tue, 12 May 2026 16:42:23 +0000 Subject: [PATCH 20/34] throw on conflicting non-selected EIP-712 struct deps --- .../solidity-test/eip712/canonicalize.ts | 69 ++++++++++++++++++- .../solidity-test/eip712/canonicalize.ts | 51 ++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts index 82b3f007d54..8c0f9ffe9b2 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -203,9 +203,12 @@ function transitiveDeps( * non decodable definition silently win over an encodable one, dropping the * struct from the canonical output. * - * Conflicts confined to non-selected names are silently deduped (first wins); - * conflicts involving a selected name still throw. Selected structs are - * processed first so they win over non-selected copies. + * Conflicts on a name reachable from any selected struct (selected roots plus + * their transitive deps) throw, since the selected struct's inlined dep head + * would otherwise depend on which conflicting copy happened to be seen first. + * Conflicts on names truly unreachable from the selected set are silently + * deduped (first wins). Selected structs are processed first so they win over + * non-selected copies. */ function indexByName( structs: CollectedStruct[], @@ -214,6 +217,10 @@ function indexByName( const byName = new Map(); const fingerprintByName = new Map(); const sourceByName = new Map(); + const deferredConflicts = new Map< + string, + { firstSource: string; secondSource: string } + >(); const ordered = selectedNames === undefined @@ -239,6 +246,12 @@ function indexByName( } if (selectedNames !== undefined && !selectedNames.has(struct.name)) { + if (!deferredConflicts.has(struct.name)) { + deferredConflicts.set(struct.name, { + firstSource: sourceByName.get(struct.name) ?? "", + secondSource: struct.sourcePath, + }); + } continue; } @@ -252,9 +265,59 @@ function indexByName( ); } + if (selectedNames !== undefined && deferredConflicts.size > 0) { + const reachable = reachableFromSelected(byName, selectedNames); + for (const [name, sources] of deferredConflicts) { + if (reachable.has(name)) { + throw new HardhatError( + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, + { name, ...sources }, + ); + } + } + } + return byName; } +/** + * Set of struct names transitively referenced by any struct in `selectedNames`. + * The walk uses whatever first-wins definition is in `byName`; that's enough + * for conflict detection since we only need to know whether a name is + * reachable, not which conflicting copy is the "right" one. + */ +function reachableFromSelected( + byName: Map, + selectedNames: Set, +): Set { + const knownNames = new Set(byName.keys()); + const reachable = new Set(); + const stack: string[] = []; + + for (const name of selectedNames) { + const root = byName.get(name); + if (root !== undefined) { + stack.push(...directStructDeps(root, knownNames)); + } + } + + while (stack.length > 0) { + const next = stack.pop(); + if (next === undefined || reachable.has(next)) { + continue; + } + + reachable.add(next); + + const def = byName.get(next); + if (def !== undefined) { + stack.push(...directStructDeps(def, knownNames)); + } + } + + return reachable; +} + /** * A stable string capturing every member of `struct`, used to detect * conflicting definitions of the same name. Unlike `encodeStructHead`, this diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts index 14d78bbca6d..e33a4839b44 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -440,6 +440,57 @@ describe("eip712 - canonicalize", () => { assert.deepEqual(result, ["Wanted(uint256 x)"]); }); + it("throws when a selected struct depends on a non-selected name with conflicting definitions", () => { + const collected = [ + struct( + "Mail", + [ + ["Person", "from"], + ["string", "contents"], + ], + "test/Mail.sol", + ), + struct( + "Person", + [ + ["address", "wallet"], + ["string", "name"], + ], + "lib/A.sol", + ), + struct("Person", [["uint256", "id"]], "lib/B.sol"), + ]; + + assertThrowsHardhatError( + () => canonicalizeStructs(collected, new Set(["Mail"])), + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, + { + name: "Person", + firstSource: "lib/A.sol", + secondSource: "lib/B.sol", + }, + ); + }); + + it("throws when a transitively-required non-selected name has conflicting definitions", () => { + const collected = [ + struct("Mail", [["Person", "from"]], "test/Mail.sol"), + struct("Person", [["Wallet", "w"]], "lib/Person.sol"), + struct("Wallet", [["address", "addr"]], "lib/A.sol"), + struct("Wallet", [["bytes32", "id"]], "lib/B.sol"), + ]; + + assertThrowsHardhatError( + () => canonicalizeStructs(collected, new Set(["Mail"])), + HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, + { + name: "Wallet", + firstSource: "lib/A.sol", + secondSource: "lib/B.sol", + }, + ); + }); + it("throws when a selected name is also defined differently in a non-selected file", () => { const collected = [ struct( From 21822a4d5d75232c0e9974a8285cce8a093c3373 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 15 May 2026 09:12:29 +0000 Subject: [PATCH 21/34] move constant --- .../builtin-plugins/solidity-test/eip712/index.ts | 2 +- .../resolver/remapped-npm-packages-graph.ts | 2 +- .../internal/builtin-plugins/solidity/constants.ts | 11 +++++++++++ .../hardhat/src/types/solidity/solidity-artifacts.ts | 11 ----------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts index a5b8229a05a..d1a841a085c 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -7,7 +7,7 @@ import type { BuildInfoAndOutput } from "../edr-artifacts.js"; import { bytesToUtf8String } from "@nomicfoundation/hardhat-utils/bytes"; -import { HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT } from "../../../../types/solidity/solidity-artifacts.js"; +import { HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT } from "../../solidity/constants.js"; import { buildUserDefinedValueTypeIndex, diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/remapped-npm-packages-graph.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/remapped-npm-packages-graph.ts index 0ae63b29890..bc376a66827 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/remapped-npm-packages-graph.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/build-system/resolver/remapped-npm-packages-graph.ts @@ -28,8 +28,8 @@ import { type PackageJson, } from "@nomicfoundation/hardhat-utils/package"; -import { HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT } from "../../../../../types/solidity/solidity-artifacts.js"; import { UserRemappingErrorType } from "../../../../../types/solidity.js"; +import { HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT } from "../../constants.js"; import { getNpmPackageName } from "./npm-module-parsing.js"; import { parseRemappingString, selectBestRemapping } from "./remappings.js"; diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity/constants.ts b/packages/hardhat/src/internal/builtin-plugins/solidity/constants.ts index 6dec9e1e1a1..1eac830d1b7 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity/constants.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity/constants.ts @@ -1,5 +1,16 @@ import type { CompilerInput } from "../../../types/solidity.js"; +/** + * The input source name prefix used for files belonging to the Hardhat + * project (as opposed to npm packages, which use `npm/@`). + * + * For example, a project file at `/contracts/Foo.sol` has input + * source name `project/contracts/Foo.sol`. Part of the build-info format + * contract: changing this value would require a new build-info format + * version. + */ +export const HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT = "project"; + export const DEFAULT_OUTPUT_SELECTION: CompilerInput["settings"]["outputSelection"] = { "*": { diff --git a/packages/hardhat/src/types/solidity/solidity-artifacts.ts b/packages/hardhat/src/types/solidity/solidity-artifacts.ts index ad161195fa5..32a2c4a736e 100644 --- a/packages/hardhat/src/types/solidity/solidity-artifacts.ts +++ b/packages/hardhat/src/types/solidity/solidity-artifacts.ts @@ -1,16 +1,5 @@ import type { CompilerInput, CompilerOutput } from "./compiler-io.js"; -/** - * The input source name prefix used for files belonging to the Hardhat - * project (as opposed to npm packages, which use `npm/@`). - * - * For example, a project file at `/contracts/Foo.sol` has input - * source name `project/contracts/Foo.sol`. Part of the build-info format - * contract: changing this value would require a new build-info format - * version. - */ -export const HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT = "project"; - /** * A record with the versions of the different tools used to create a * build info. From 9fc53e148a90801075ab289d1f663113adf0ee4a Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 15 May 2026 09:44:35 +0000 Subject: [PATCH 22/34] move eip712Types destructure to task-action boundary --- .../src/internal/builtin-plugins/solidity-test/helpers.ts | 7 ++----- .../internal/builtin-plugins/solidity-test/task-action.ts | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/helpers.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/helpers.ts index 96ce50a31ec..5c602b33f5e 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/helpers.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/helpers.ts @@ -32,7 +32,7 @@ interface SolidityTestConfigParams { chainType: ChainType; projectRoot: string; hardfork?: string; - config: SolidityTestConfig; + config: Omit; verbosity: number; observability?: ObservabilityConfig; testPattern?: string; @@ -124,13 +124,10 @@ export async function solidityTestConfigToSolidityTestRunnerConfigArgs({ const shouldAlwaysCollectStackTraces = verbosity > DEFAULT_VERBOSITY; - // `eip712Types` must be passed as a separate param, not via `config` - const { eip712Types: _, ...configWithoutEip712 } = config; - return { projectRoot, hardfork: resolvedHardfork, - ...configWithoutEip712, + ...config, fsPermissions, localPredeploys, sender, diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts index 8fa00ee115b..1285d19a54a 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts @@ -201,7 +201,7 @@ const runSolidityTests: NewTaskActionFunction = async ( let includesFailures = false; let includesErrors = false; - const solidityTestConfig = hre.config.test.solidity; + const { eip712Types, ...solidityTestConfig } = hre.config.test.solidity; let observabilityConfig: ObservabilityConfig | undefined; if (hre.globalOptions.coverage) { const coverage = getCoverageManager(hre); @@ -235,7 +235,7 @@ const runSolidityTests: NewTaskActionFunction = async ( const eip712CanonicalTypes = collectEip712CanonicalTypes( allBuildInfosAndOutputs, - solidityTestConfig.eip712Types, + eip712Types, ); const testRunnerConfig = From 1feac9d311d3edffb558190736f9f0a4cf1d3769 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 15 May 2026 09:44:44 +0000 Subject: [PATCH 23/34] simplify comment --- .../solidity-test/eip712/index.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts index d1a841a085c..25aa8482980 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -77,14 +77,20 @@ export function collectEip712CanonicalTypes( continue; } - // Build the user-defined value type index *per build info*. solc node ids - // are unique only within a single compilation, so pooling the indexes - // across build infos would let one compilation's user-defined value type - // shadow another at the same numeric id and silently resolve - // `referencedDeclaration` to the wrong underlying type. The index still - // has to span every source in *this* build (not just include-matched ones) - // because a struct in an included file may reference a user-defined value - // type defined in a non-included file. + // Two constraints determine the index's scope: + // + // 1. Per build info, not pooled across them. solc assigns node ids + // fresh in each compilation, so the same numeric id can mean + // different user-defined value types in different builds. Pooling + // would let one compilation's definition silently overwrite + // another's at the same key, mis-resolving `referencedDeclaration`. + // See the test "scopes user-defined value type resolution per build + // info when node ids collide" for a repro. + // + // 2. Whole build info, not narrowed to a subset of sources. A struct + // member's `referencedDeclaration` can point at a user-defined + // value type defined in any source within the same compilation, so + // the index must cover every source in the build. const userDefinedValueTypeI = buildUserDefinedValueTypeIndex( Object.values(sources).map((s) => s.ast), ); From 3e7fef7d4de4266ae4c9432d85ce965bafdf562f Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 15 May 2026 09:48:06 +0000 Subject: [PATCH 24/34] resolve eip712Types defaults at config resolution --- .../builtin-plugins/solidity-test/config.ts | 12 ++++++ .../solidity-test/eip712/index.ts | 9 ++-- .../solidity-test/type-extensions.ts | 4 ++ .../solidity-test/eip712/index.ts | 43 +++++++++++++------ 4 files changed, 50 insertions(+), 18 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/config.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/config.ts index 41d2cd72ebf..951c5b99142 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/config.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/config.ts @@ -175,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 { @@ -201,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 ?? [], + }; +} diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts index 25aa8482980..db59c5b7f69 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -17,8 +17,8 @@ import { canonicalizeStructs } from "./canonicalize.js"; import { isPathSelected } from "./glob.js"; export interface Eip712TypesConfig { - include?: string[]; - exclude?: string[]; + include: string[]; + exclude: string[]; } // When a transitive project file isn't a root in any build info — and so @@ -39,10 +39,9 @@ const PROJECT_INPUT_SOURCE_NAME_PREFIX = `${HARDHAT_PROJECT_INPUT_SOURCE_NAME_RO */ export function collectEip712CanonicalTypes( buildInfosAndOutputs: BuildInfoAndOutput[], - config: Eip712TypesConfig | undefined, + config: Eip712TypesConfig, ): string[] { - const include = config?.include ?? []; - const exclude = config?.exclude ?? []; + const { include, exclude } = config; if (include.length === 0) { return []; diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/type-extensions.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/type-extensions.ts index 3aa9f2992c3..cdfa1baf232 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/type-extensions.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/type-extensions.ts @@ -94,6 +94,10 @@ declare module "../../../types/test.js" { export interface SolidityTestConfig extends SolidityTestConfigBase { fuzz: SolidityTestFuzzConfig; forking?: SolidityTestForkingConfig; + eip712Types: { + include: string[]; + exclude: string[]; + }; } export interface HardhatTestConfig { solidity: SolidityTestConfig; diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts index 36448070e12..09c87585fde 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -112,8 +112,8 @@ function contractAst(name: string, structs: unknown[]): unknown { describe("eip712 - collectEip712CanonicalTypes", () => { it("returns an empty list when no include is configured", () => { - // The feature is opt-in: without `include`, collection short-circuits - // before any build info is parsed. + // The feature is opt-in: with an empty `include`, collection + // short-circuits before any build info is parsed. const buildInfo = makeBuildInfo("solc-0_8_23-00000000", [ { inputSourceName: "project/test/Types.sol", @@ -122,16 +122,16 @@ describe("eip712 - collectEip712CanonicalTypes", () => { }, ]); - // All possible scenarios where `include` is empty/unset: - assert.deepEqual(collectEip712CanonicalTypes([buildInfo], undefined), []); - assert.deepEqual(collectEip712CanonicalTypes([buildInfo], {}), []); assert.deepEqual( - collectEip712CanonicalTypes([buildInfo], { include: [] }), + collectEip712CanonicalTypes([buildInfo], { include: [], exclude: [] }), [], ); // Exclude alone is a no-op without an include to narrow. assert.deepEqual( - collectEip712CanonicalTypes([buildInfo], { exclude: ["**"] }), + collectEip712CanonicalTypes([buildInfo], { + include: [], + exclude: ["**"], + }), [], ); }); @@ -157,6 +157,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { const result = collectEip712CanonicalTypes([buildInfo], { include: ["test/**"], + exclude: [], }); assert.deepEqual(result, [ @@ -181,6 +182,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { const onlyTests = collectEip712CanonicalTypes([buildInfo], { include: ["test/**"], + exclude: [], }); assert.deepEqual(onlyTests, ["Bar(uint256 y)"]); @@ -215,6 +217,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { const result = collectEip712CanonicalTypes([a, b], { include: ["test/**"], + exclude: [], }); assert.deepEqual(result, ["Person(address wallet,string name)"]); @@ -270,7 +273,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { output: utf8StringToBytes(JSON.stringify(partialOutput)), }, ], - { include: ["contracts/**"] }, + { include: ["contracts/**"], exclude: [] }, ); assert.deepEqual(result, ["Person(address wallet,string name)"]); @@ -312,7 +315,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { output: utf8StringToBytes(JSON.stringify(output)), }, ], - { include: ["npm/**"] }, + { include: ["npm/**"], exclude: [] }, ); assert.deepEqual(result, ["Imported(uint256 x)"]); @@ -357,7 +360,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { output: utf8StringToBytes(JSON.stringify(output)), }, ], - { include: ["lib/**"] }, + { include: ["lib/**"], exclude: [] }, ); assert.deepEqual(result, ["Helper(uint256 n)"]); @@ -384,6 +387,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { () => collectEip712CanonicalTypes([buildInfo], { include: ["test/**"], + exclude: [], }), HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, { @@ -410,6 +414,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { () => collectEip712CanonicalTypes([buildInfo], { include: ["test/**"], + exclude: [], }), HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, { @@ -437,6 +442,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { const result = collectEip712CanonicalTypes([buildInfo], { include: ["test/**"], + exclude: [], }); assert.deepEqual(result, ["S(uint256 a)"]); @@ -532,11 +538,17 @@ describe("eip712 - collectEip712CanonicalTypes", () => { const expected = ["AStruct(uint256 f)", "BStruct(bytes32 b)"]; assert.deepEqual( - collectEip712CanonicalTypes([buildA, buildB], { include: ["**"] }).sort(), + collectEip712CanonicalTypes([buildA, buildB], { + include: ["**"], + exclude: [], + }).sort(), expected, ); assert.deepEqual( - collectEip712CanonicalTypes([buildB, buildA], { include: ["**"] }).sort(), + collectEip712CanonicalTypes([buildB, buildA], { + include: ["**"], + exclude: [], + }).sort(), expected, ); }); @@ -568,6 +580,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { const result = collectEip712CanonicalTypes([buildInfo], { include: ["test/**"], + exclude: [], }); assert.deepEqual(result, [ @@ -603,6 +616,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { const result = collectEip712CanonicalTypes([testBuild, libBuild], { include: ["test/**"], + exclude: [], }); assert.deepEqual(result, [ @@ -646,6 +660,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { const result = collectEip712CanonicalTypes([buildInfo], { include: ["test/**"], + exclude: [], }); assert.deepEqual(result, [ @@ -682,6 +697,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { const result = collectEip712CanonicalTypes([buildInfo], { include: ["test/**"], + exclude: [], }); assert.deepEqual(result, ["Wanted(uint256 x)"]); @@ -728,6 +744,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { const result = collectEip712CanonicalTypes([buildInfo], { include: ["test/**"], + exclude: [], }); assert.deepEqual(result, []); @@ -774,7 +791,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { }, goodBuildInfo, ], - { include: ["test/**"] }, + { include: ["test/**"], exclude: [] }, ); assert.deepEqual(result, ["Person(address wallet,string name)"]); From f545768df60a098e3cd5c20f5ba667323c778c9f Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 15 May 2026 10:01:28 +0000 Subject: [PATCH 25/34] return undefined for unresolvable array length --- .../builtin-plugins/solidity-test/eip712/ast-walker.ts | 4 ++-- .../builtin-plugins/solidity-test/eip712/ast-walker.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index 61bef744487..d1bb33ac7ed 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -276,7 +276,7 @@ export function encodeMemberType( } const length = typeName.length; - if (length === null || length === undefined) { + if (length === null) { return `${base}[]`; } @@ -302,7 +302,7 @@ export function encodeMemberType( return `${base}[${match[1]}]`; } - return `${base}[]`; + return undefined; } case "Mapping": diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index f865a667f83..247e82e8074 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -396,14 +396,14 @@ describe("eip712 - ast-walker", () => { ); }); - it("falls back to [] when length is non-literal and typeString has no resolved size", () => { + it("returns undefined when length is non-literal and typeString has no resolved size", () => { assert.equal( encodeMemberType({ nodeType: "ArrayTypeName", baseType: { nodeType: "ElementaryTypeName", name: "uint256" }, length: { nodeType: "Identifier", name: "N" }, }), - "uint256[]", + undefined, ); }); From 883477f0ee29b4770ecde5c756c56b88b16a4316 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 15 May 2026 10:08:24 +0000 Subject: [PATCH 26/34] normalize EIP-712 elementary type aliases via typeString --- .../solidity-test/eip712/ast-walker.ts | 16 ++++++ .../solidity-test/eip712/ast-walker.ts | 52 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index d1bb33ac7ed..7e70e40c01a 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -196,6 +196,22 @@ export function encodeMemberType( 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; } diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index 247e82e8074..52d8333e127 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -211,7 +211,57 @@ describe("eip712 - ast-walker", () => { ); }); - it("returns undefined for elementary types with no name", () => { + it("normalizes aliases via typeDescriptions.typeString", () => { + // solc emits `name: "uint"` but `typeString: "uint256"` for `uint`. + // EIP-712 only knows the canonical form. + assert.equal( + encodeMemberType({ + nodeType: "ElementaryTypeName", + name: "uint", + typeDescriptions: { typeString: "uint256" }, + }), + "uint256", + ); + + assert.equal( + encodeMemberType({ + nodeType: "ElementaryTypeName", + name: "int", + typeDescriptions: { typeString: "int256" }, + }), + "int256", + ); + + assert.equal( + encodeMemberType({ + nodeType: "ElementaryTypeName", + name: "byte", + typeDescriptions: { typeString: "bytes1" }, + }), + "bytes1", + ); + }); + + it("strips the ` payable` suffix from address payable", () => { + assert.equal( + encodeMemberType({ + nodeType: "ElementaryTypeName", + name: "address", + stateMutability: "payable", + typeDescriptions: { typeString: "address payable" }, + }), + "address", + ); + }); + + it("falls back to name when typeDescriptions.typeString is missing", () => { + assert.equal( + encodeMemberType({ nodeType: "ElementaryTypeName", name: "uint256" }), + "uint256", + ); + }); + + it("returns undefined for elementary types with no name or typeString", () => { assert.equal( encodeMemberType({ nodeType: "ElementaryTypeName" }), undefined, From 27191bfd7a9de9a87cfe16939ec0fda3522b92e2 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 15 May 2026 10:13:19 +0000 Subject: [PATCH 27/34] remove redundant Literal branch in array length encoding --- .../solidity-test/eip712/ast-walker.ts | 13 ++------ .../solidity-test/eip712/ast-walker.ts | 33 +++++++++++++++++-- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index 7e70e40c01a..24a4ba07370 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -296,17 +296,8 @@ export function encodeMemberType( return `${base}[]`; } - if ( - isObject(length) && - length.nodeType === "Literal" && - typeof length.value === "string" - ) { - return `${base}[${length.value}]`; - } - - // The length wasn't a plain literal (e.g. `uint[CONST]`). solc still - // records the resolved size in the array's `typeString`, so parse it - // from there. + // 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; diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index 52d8333e127..93ae320a3a2 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -423,18 +423,45 @@ describe("eip712 - ast-walker", () => { ); }); - it("encodes fixed-size arrays with [N] using the literal value", () => { + it("encodes fixed-size arrays with [N] using the canonical typeString size", () => { assert.equal( encodeMemberType({ nodeType: "ArrayTypeName", baseType: { nodeType: "ElementaryTypeName", name: "uint256" }, length: { nodeType: "Literal", value: "3" }, + typeDescriptions: { typeString: "uint256[3]" }, }), "uint256[3]", ); }); - it("falls back to typeString for fixed-size with constant-expr length", () => { + it("canonicalizes hex literal lengths via typeString", () => { + // `bytes32[0x100]`: solc keeps `value: "0x100"` on the Literal but + // emits the resolved decimal size in the array's typeString. + assert.equal( + encodeMemberType({ + nodeType: "ArrayTypeName", + baseType: { nodeType: "ElementaryTypeName", name: "bytes32" }, + length: { nodeType: "Literal", value: "0x100" }, + typeDescriptions: { typeString: "bytes32[256]" }, + }), + "bytes32[256]", + ); + }); + + it("canonicalizes underscore-separated literal lengths via typeString", () => { + assert.equal( + encodeMemberType({ + nodeType: "ArrayTypeName", + baseType: { nodeType: "ElementaryTypeName", name: "uint256" }, + length: { nodeType: "Literal", value: "1_000" }, + typeDescriptions: { typeString: "uint256[1000]" }, + }), + "uint256[1000]", + ); + }); + + it("resolves constant-expression lengths via typeString", () => { assert.equal( encodeMemberType({ nodeType: "ArrayTypeName", @@ -446,7 +473,7 @@ describe("eip712 - ast-walker", () => { ); }); - it("returns undefined when length is non-literal and typeString has no resolved size", () => { + it("returns undefined when typeString has no resolved size", () => { assert.equal( encodeMemberType({ nodeType: "ArrayTypeName", From 76523d586e06621fd9ce1b347354fc989e5122e6 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 15 May 2026 10:15:38 +0000 Subject: [PATCH 28/34] remove dead interface branch in EIP-712 type encoder --- .../builtin-plugins/solidity-test/eip712/ast-walker.ts | 8 +++----- .../builtin-plugins/solidity-test/eip712/ast-walker.ts | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index 24a4ba07370..1713be018b1 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -218,7 +218,8 @@ export function encodeMemberType( 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", "interface IFoo". The AST node itself doesn't say. + // "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; @@ -229,10 +230,7 @@ export function encodeMemberType( return "uint8"; } - if ( - typeString.startsWith("contract ") || - typeString.startsWith("interface ") - ) { + if (typeString.startsWith("contract ")) { return "address"; } diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index 93ae320a3a2..e5677305de7 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -280,7 +280,7 @@ describe("eip712 - ast-walker", () => { ); }); - it("encodes contracts and interfaces as address", () => { + it("encodes contracts as address (solc uses the `contract ` prefix for interface references too)", () => { assert.equal( encodeMemberType({ nodeType: "UserDefinedTypeName", @@ -291,7 +291,7 @@ describe("eip712 - ast-walker", () => { assert.equal( encodeMemberType({ nodeType: "UserDefinedTypeName", - typeDescriptions: { typeString: "interface IFoo" }, + typeDescriptions: { typeString: "contract IFoo" }, }), "address", ); From 62cf8b9e614e05d215e8c7a949fb912de2daa134 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 15 May 2026 10:37:25 +0000 Subject: [PATCH 29/34] skip parsing build infos that can't define EIP-712 types --- .../solidity-test/eip712/index.ts | 54 +-- .../solidity-test/task-action.ts | 1 + .../solidity-test/eip712/index.ts | 459 ++++++++++-------- 3 files changed, 280 insertions(+), 234 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts index db59c5b7f69..fd1245d4a25 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -1,11 +1,11 @@ import type { CollectedStruct } from "./ast-walker.js"; -import type { - SolidityBuildInfo, - SolidityBuildInfoOutput, -} from "../../../../types/solidity/solidity-artifacts.js"; +import type { SolidityBuildInfoOutput } from "../../../../types/solidity/solidity-artifacts.js"; import type { BuildInfoAndOutput } from "../edr-artifacts.js"; -import { bytesToUtf8String } from "@nomicfoundation/hardhat-utils/bytes"; +import { + bytesIncludesUtf8String, + bytesToUtf8String, +} from "@nomicfoundation/hardhat-utils/bytes"; import { HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT } from "../../solidity/constants.js"; @@ -21,10 +21,9 @@ export interface Eip712TypesConfig { exclude: string[]; } -// When a transitive project file isn't a root in any build info — and so -// is missing from every userSourceNameMap — stripping this prefix recovers -// the user-facing path that the user's include/exclude globs are written -// against. +// When a transitive project file doesn't produce an artifact — and so is +// missing from `inputToUserSource` — stripping this prefix recovers the +// user-facing path that the user's include/exclude globs are written against. const PROJECT_INPUT_SOURCE_NAME_PREFIX = `${HARDHAT_PROJECT_INPUT_SOURCE_NAME_ROOT}/`; /** @@ -34,11 +33,16 @@ const PROJECT_INPUT_SOURCE_NAME_PREFIX = `${HARDHAT_PROJECT_INPUT_SOURCE_NAME_RO * `include`/`exclude` are emitted; non-selected sources still feed the dep * graph so cross-file deps inline correctly. * + * `inputToUserSource` maps solc input source names to user source names; it's + * built by the caller from the artifact set so we don't pay to parse every + * build info just to recover that mapping. + * * When `include` is empty/unset the feature is off: collection short-circuits * and returns an empty list without parsing any build info. */ export function collectEip712CanonicalTypes( buildInfosAndOutputs: BuildInfoAndOutput[], + inputToUserSource: ReadonlyMap, config: Eip712TypesConfig, ): string[] { const { include, exclude } = config; @@ -47,31 +51,21 @@ export function collectEip712CanonicalTypes( return []; } - const parsed = buildInfosAndOutputs.map(({ buildInfo, output }) => { - const parsedBuildInfo: SolidityBuildInfo = JSON.parse( - bytesToUtf8String(buildInfo), - ); - const parsedOutput: SolidityBuildInfoOutput = JSON.parse( - bytesToUtf8String(output), - ); - - return { buildInfo: parsedBuildInfo, output: parsedOutput }; - }); + const collected: CollectedStruct[] = []; + const selectedNames = new Set(); - const inputToUserSource = new Map(); - for (const { buildInfo } of parsed) { - for (const [userSource, inputSource] of Object.entries( - buildInfo.userSourceNameMap, - )) { - inputToUserSource.set(inputSource, userSource); + for (const { buildInfo, output } of buildInfosAndOutputs) { + // Byte-level fast path: a build info whose source bytes don't contain + // `struct ` can't define any EIP-712 type, so skip JSON-parsing its output. + if (!bytesIncludesUtf8String(buildInfo, "struct ")) { + continue; } - } - const collected: CollectedStruct[] = []; - const selectedNames = new Set(); + const parsedOutput: SolidityBuildInfoOutput = JSON.parse( + bytesToUtf8String(output), + ); - for (const { output } of parsed) { - const sources = output.output.sources; + const sources = parsedOutput.output.sources; if (sources === undefined) { continue; } diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts index 1285d19a54a..be7966a6384 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/task-action.ts @@ -235,6 +235,7 @@ const runSolidityTests: NewTaskActionFunction = async ( const eip712CanonicalTypes = collectEip712CanonicalTypes( allBuildInfosAndOutputs, + sourceNameToUserSourceName, eip712Types, ); diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts index 09c87585fde..432ae0cd2e3 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/index.ts @@ -19,17 +19,19 @@ function makeBuildInfo( buildInfoId: string, sources: FakeSource[], ): BuildInfoAndOutput { - const userSourceNameMap: Record = {}; + // The collector skips build infos whose bytes don't contain `struct `, so + // the fixtures must include a struct-like content blob in `input.sources` + // for the parse path to be exercised. + const inputSources: Record = {}; for (const s of sources) { - userSourceNameMap[s.userSourceName] = s.inputSourceName; + inputSources[s.inputSourceName] = { content: "struct _Stub {}" }; } const buildInfo = { _format: "hh3-sol-build-info-1", id: buildInfoId, solcVersion: "0.8.23", solcLongVersion: "0.8.23+commit.f704f362", - userSourceNameMap, - input: { language: "Solidity", sources: {}, settings: {} }, + input: { language: "Solidity", sources: inputSources, settings: {} }, }; const outputSources: Record = {}; @@ -50,6 +52,20 @@ function makeBuildInfo( }; } +function inputToUserSourceMap( + ...sourceLists: FakeSource[][] +): Map { + const map = new Map(); + + for (const list of sourceLists) { + for (const s of list) { + map.set(s.inputSourceName, s.userSourceName); + } + } + + return map; +} + function structAst( name: string, members: Array<{ type: string; name: string }>, @@ -114,21 +130,26 @@ describe("eip712 - collectEip712CanonicalTypes", () => { it("returns an empty list when no include is configured", () => { // The feature is opt-in: with an empty `include`, collection // short-circuits before any build info is parsed. - const buildInfo = makeBuildInfo("solc-0_8_23-00000000", [ + const sources: FakeSource[] = [ { inputSourceName: "project/test/Types.sol", userSourceName: "test/Types.sol", ast: sourceUnit([structAst("Foo", [{ type: "uint256", name: "x" }])]), }, - ]); + ]; + const buildInfo = makeBuildInfo("solc-0_8_23-00000000", sources); + const inputToUserSource = inputToUserSourceMap(sources); assert.deepEqual( - collectEip712CanonicalTypes([buildInfo], { include: [], exclude: [] }), + collectEip712CanonicalTypes([buildInfo], inputToUserSource, { + include: [], + exclude: [], + }), [], ); // Exclude alone is a no-op without an include to narrow. assert.deepEqual( - collectEip712CanonicalTypes([buildInfo], { + collectEip712CanonicalTypes([buildInfo], inputToUserSource, { include: [], exclude: ["**"], }), @@ -137,7 +158,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { }); it("returns the flat canonical list for a Mail/Person fixture", () => { - const buildInfo = makeBuildInfo("solc-0_8_23-aaaaaaaa", [ + const sources: FakeSource[] = [ { inputSourceName: "project/test/Types.sol", userSourceName: "test/Types.sol", @@ -153,12 +174,14 @@ describe("eip712 - collectEip712CanonicalTypes", () => { ]), ]), }, - ]); + ]; + const buildInfo = makeBuildInfo("solc-0_8_23-aaaaaaaa", sources); - const result = collectEip712CanonicalTypes([buildInfo], { - include: ["test/**"], - exclude: [], - }); + const result = collectEip712CanonicalTypes( + [buildInfo], + inputToUserSourceMap(sources), + { include: ["test/**"], exclude: [] }, + ); assert.deepEqual(result, [ "Mail(Person from,Person to,string contents)Person(address wallet,string name)", @@ -167,7 +190,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { }); it("filters by include/exclude on the user source name", () => { - const buildInfo = makeBuildInfo("solc-0_8_23-bbbbbbbb", [ + const sources: FakeSource[] = [ { inputSourceName: "project/contracts/Foo.sol", userSourceName: "contracts/Foo.sol", @@ -178,18 +201,22 @@ describe("eip712 - collectEip712CanonicalTypes", () => { userSourceName: "test/Bar.sol", ast: sourceUnit([structAst("Bar", [{ type: "uint256", name: "y" }])]), }, - ]); + ]; + const buildInfo = makeBuildInfo("solc-0_8_23-bbbbbbbb", sources); + const inputToUserSource = inputToUserSourceMap(sources); - const onlyTests = collectEip712CanonicalTypes([buildInfo], { - include: ["test/**"], - exclude: [], - }); + const onlyTests = collectEip712CanonicalTypes( + [buildInfo], + inputToUserSource, + { include: ["test/**"], exclude: [] }, + ); assert.deepEqual(onlyTests, ["Bar(uint256 y)"]); - const excludeTests = collectEip712CanonicalTypes([buildInfo], { - include: ["**"], - exclude: ["test/**"], - }); + const excludeTests = collectEip712CanonicalTypes( + [buildInfo], + inputToUserSource, + { include: ["**"], exclude: ["test/**"] }, + ); assert.deepEqual(excludeTests, ["Foo(uint256 x)"]); }); @@ -200,168 +227,117 @@ describe("eip712 - collectEip712CanonicalTypes", () => { { type: "string", name: "name" }, ]), ]); - const a = makeBuildInfo("solc-0_8_23-cccccccc", [ + const sourcesA: FakeSource[] = [ { inputSourceName: "project/test/Types.sol", userSourceName: "test/Types.sol", ast, }, - ]); - const b = makeBuildInfo("solc-0_8_23-dddddddd", [ + ]; + const sourcesB: FakeSource[] = [ { inputSourceName: "project/test/Types.sol", userSourceName: "test/Types.sol", ast, }, - ]); + ]; + const a = makeBuildInfo("solc-0_8_23-cccccccc", sourcesA); + const b = makeBuildInfo("solc-0_8_23-dddddddd", sourcesB); - const result = collectEip712CanonicalTypes([a, b], { - include: ["test/**"], - exclude: [], - }); + const result = collectEip712CanonicalTypes( + [a, b], + inputToUserSourceMap(sourcesA, sourcesB), + { include: ["test/**"], exclude: [] }, + ); assert.deepEqual(result, ["Person(address wallet,string name)"]); }); - it("resolves user source names across build infos", () => { - // Mirrors `hardhat test solidity `: the partial build - // info's `userSourceNameMap` only registers the explicitly requested - // file, but its output contains the full transitive source set. The - // user-source name for the transitive source must come from the full - // build info that ran earlier. + it("uses the caller-provided inputToUserSource map for transitive sources", () => { + // Mirrors `hardhat test solidity `: the partial build info + // explicitly compiles a single root, but its output also contains the + // transitive source set. The user-facing name for those transitive + // sources comes from the caller-supplied map (built from the artifact + // set), not from anything inside the build info itself. const sharedAst = sourceUnit([ structAst("Person", [ { type: "address", name: "wallet" }, { type: "string", name: "name" }, ]), ]); - const fullBuild = makeBuildInfo("solc-0_8_23-ffffffff", [ + const fullBuildSources: FakeSource[] = [ { inputSourceName: "project/contracts/Types.sol", userSourceName: "contracts/Types.sol", ast: sourceUnit([]), // not the source we care about here }, - ]); - const partialBuildId = "solc-0_8_23-aaaa1111"; - const partialBuildInfo = { - _format: "hh3-sol-build-info-1", - id: partialBuildId, - solcVersion: "0.8.23", - solcLongVersion: "0.8.23+commit.f704f362", - userSourceNameMap: { - "test/Foo.t.sol": "project/test/Foo.t.sol", - }, - input: { language: "Solidity", sources: {}, settings: {} }, - }; - const partialOutput = { - _format: "hh3-sol-build-info-output-1", - id: partialBuildId, - output: { - sources: { - // Pulled in transitively, but not in this build info's own map. - "project/contracts/Types.sol": { id: 0, ast: sharedAst }, - }, + ]; + const fullBuild = makeBuildInfo("solc-0_8_23-ffffffff", fullBuildSources); + const partialBuildSources: FakeSource[] = [ + { + inputSourceName: "project/contracts/Types.sol", + userSourceName: "contracts/Types.sol", + ast: sharedAst, }, - }; + ]; + const partialBuild = makeBuildInfo( + "solc-0_8_23-aaaa1111", + partialBuildSources, + ); const result = collectEip712CanonicalTypes( - [ - fullBuild, - { - buildInfoId: partialBuildId, - buildInfo: utf8StringToBytes(JSON.stringify(partialBuildInfo)), - output: utf8StringToBytes(JSON.stringify(partialOutput)), - }, - ], + [fullBuild, partialBuild], + inputToUserSourceMap(fullBuildSources, partialBuildSources), { include: ["contracts/**"], exclude: [] }, ); assert.deepEqual(result, ["Person(address wallet,string name)"]); }); - it("falls back to inputSourceName when userSourceNameMap omits an entry", () => { - // Imported sources (e.g. from npm packages) aren't registered as roots, - // so they don't appear in `userSourceNameMap`. The orchestrator should - // still surface their structs, keyed by the input source name. - const buildInfoId = "solc-0_8_23-eeeeeeee"; - const buildInfo = { - _format: "hh3-sol-build-info-1", - id: buildInfoId, - solcVersion: "0.8.23", - solcLongVersion: "0.8.23+commit.f704f362", - userSourceNameMap: {}, // no roots - input: { language: "Solidity", sources: {}, settings: {} }, - }; - const output = { - _format: "hh3-sol-build-info-output-1", - id: buildInfoId, - output: { - sources: { - "npm/some-pkg/Types.sol": { - id: 0, - ast: sourceUnit([ - structAst("Imported", [{ type: "uint256", name: "x" }]), - ]), - }, - }, + it("falls back to inputSourceName when the map omits an entry", () => { + // Imported sources (e.g. from npm packages) that don't produce artifacts + // won't appear in the caller-supplied `inputToUserSource` map. The + // collector should still surface their structs, keyed by the input + // source name as a fallback. + const buildInfo = makeBuildInfo("solc-0_8_23-eeeeeeee", [ + { + inputSourceName: "npm/some-pkg/Types.sol", + userSourceName: "npm/some-pkg/Types.sol", + ast: sourceUnit([ + structAst("Imported", [{ type: "uint256", name: "x" }]), + ]), }, - }; + ]); - const result = collectEip712CanonicalTypes( - [ - { - buildInfoId, - buildInfo: utf8StringToBytes(JSON.stringify(buildInfo)), - output: utf8StringToBytes(JSON.stringify(output)), - }, - ], - { include: ["npm/**"], exclude: [] }, - ); + const result = collectEip712CanonicalTypes([buildInfo], new Map(), { + include: ["npm/**"], + exclude: [], + }); assert.deepEqual(result, ["Imported(uint256 x)"]); }); - it("strips the project/ prefix when a project file is missing from every userSourceNameMap", () => { + it("strips the project/ prefix when a project file is missing from the map", () => { // A project file outside the standard root directories (e.g. a shared - // file in `lib/` that's only ever imported, never compiled as a root) - // never appears in any build info's userSourceNameMap. Its input source - // name is `project/lib/Helper.sol`. Falling back to that raw path would - // make user globs like `lib/**` miss it. The collector strips the + // file in `lib/` that's only ever imported and produces no artifact) is + // absent from the caller-supplied map. Its input source name is + // `project/lib/Helper.sol` — falling back to that raw path would make + // user globs like `lib/**` miss it, so the collector strips the // `project/` prefix to recover the user-facing path. - const buildInfoId = "solc-0_8_23-cccccccc"; - const buildInfo = { - _format: "hh3-sol-build-info-1", - id: buildInfoId, - solcVersion: "0.8.23", - solcLongVersion: "0.8.23+commit.f704f362", - userSourceNameMap: {}, // transitive project file: not a root anywhere - input: { language: "Solidity", sources: {}, settings: {} }, - }; - const output = { - _format: "hh3-sol-build-info-output-1", - id: buildInfoId, - output: { - sources: { - "project/lib/Helper.sol": { - id: 0, - ast: sourceUnit([ - structAst("Helper", [{ type: "uint256", name: "n" }]), - ]), - }, - }, + const buildInfo = makeBuildInfo("solc-0_8_23-cccccccc", [ + { + inputSourceName: "project/lib/Helper.sol", + userSourceName: "lib/Helper.sol", + ast: sourceUnit([ + structAst("Helper", [{ type: "uint256", name: "n" }]), + ]), }, - }; + ]); - const result = collectEip712CanonicalTypes( - [ - { - buildInfoId, - buildInfo: utf8StringToBytes(JSON.stringify(buildInfo)), - output: utf8StringToBytes(JSON.stringify(output)), - }, - ], - { include: ["lib/**"], exclude: [] }, - ); + const result = collectEip712CanonicalTypes([buildInfo], new Map(), { + include: ["lib/**"], + exclude: [], + }); assert.deepEqual(result, ["Helper(uint256 n)"]); }); @@ -372,7 +348,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { // EIP-712 heads. Since `vm.eip712HashType` resolves by bare name, this // is genuinely ambiguous and must surface as an error rather than be // silently resolved by AST traversal order. - const buildInfo = makeBuildInfo("solc-0_8_23-11111111", [ + const sources: FakeSource[] = [ { inputSourceName: "project/test/Types.sol", userSourceName: "test/Types.sol", @@ -381,14 +357,16 @@ describe("eip712 - collectEip712CanonicalTypes", () => { contractAst("C", [structAst("S", [{ type: "uint256", name: "b" }])]), ]), }, - ]); + ]; + const buildInfo = makeBuildInfo("solc-0_8_23-11111111", sources); assertThrowsHardhatError( () => - collectEip712CanonicalTypes([buildInfo], { - include: ["test/**"], - exclude: [], - }), + collectEip712CanonicalTypes( + [buildInfo], + inputToUserSourceMap(sources), + { include: ["test/**"], exclude: [] }, + ), HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, { name: "S", @@ -399,7 +377,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { }); it("throws on conflicting same-named structs across two contracts in one file", () => { - const buildInfo = makeBuildInfo("solc-0_8_23-22222222", [ + const sources: FakeSource[] = [ { inputSourceName: "project/test/Types.sol", userSourceName: "test/Types.sol", @@ -408,14 +386,16 @@ describe("eip712 - collectEip712CanonicalTypes", () => { contractAst("B", [structAst("S", [{ type: "uint256", name: "b" }])]), ]), }, - ]); + ]; + const buildInfo = makeBuildInfo("solc-0_8_23-22222222", sources); assertThrowsHardhatError( () => - collectEip712CanonicalTypes([buildInfo], { - include: ["test/**"], - exclude: [], - }), + collectEip712CanonicalTypes( + [buildInfo], + inputToUserSourceMap(sources), + { include: ["test/**"], exclude: [] }, + ), HardhatError.ERRORS.CORE.SOLIDITY_TESTS.EIP712_DUPLICATE_STRUCT_NAME, { name: "S", @@ -429,7 +409,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { // A top-level `struct S` and a `contract C { struct S { ... } }` with // an identical definition produce the same EIP-712 head; that's not a // conflict and must be silently deduped. - const buildInfo = makeBuildInfo("solc-0_8_23-33333333", [ + const sources: FakeSource[] = [ { inputSourceName: "project/test/Types.sol", userSourceName: "test/Types.sol", @@ -438,12 +418,14 @@ describe("eip712 - collectEip712CanonicalTypes", () => { contractAst("C", [structAst("S", [{ type: "uint256", name: "a" }])]), ]), }, - ]); + ]; + const buildInfo = makeBuildInfo("solc-0_8_23-33333333", sources); - const result = collectEip712CanonicalTypes([buildInfo], { - include: ["test/**"], - exclude: [], - }); + const result = collectEip712CanonicalTypes( + [buildInfo], + inputToUserSourceMap(sources), + { include: ["test/**"], exclude: [] }, + ); assert.deepEqual(result, ["S(uint256 a)"]); }); @@ -459,7 +441,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { // `contracts` and `tests` artifact dirs into the collector. const sharedId = 5; - const buildA = makeBuildInfo("solc-0_8_23-aaaa1111", [ + const sourcesA: FakeSource[] = [ { inputSourceName: "project/contracts/A.sol", userSourceName: "contracts/A.sol", @@ -494,9 +476,10 @@ describe("eip712 - collectEip712CanonicalTypes", () => { ], }, }, - ]); + ]; + const buildA = makeBuildInfo("solc-0_8_23-aaaa1111", sourcesA); - const buildB = makeBuildInfo("solc-0_8_23-bbbb2222", [ + const sourcesB: FakeSource[] = [ { inputSourceName: "project/test/B.t.sol", userSourceName: "test/B.t.sol", @@ -531,21 +514,24 @@ describe("eip712 - collectEip712CanonicalTypes", () => { ], }, }, - ]); + ]; + const buildB = makeBuildInfo("solc-0_8_23-bbbb2222", sourcesB); + + const inputToUserSource = inputToUserSourceMap(sourcesA, sourcesB); // Both orderings must produce the same answer — iteration order over // `buildInfosAndOutputs` is not something callers can control. const expected = ["AStruct(uint256 f)", "BStruct(bytes32 b)"]; assert.deepEqual( - collectEip712CanonicalTypes([buildA, buildB], { + collectEip712CanonicalTypes([buildA, buildB], inputToUserSource, { include: ["**"], exclude: [], }).sort(), expected, ); assert.deepEqual( - collectEip712CanonicalTypes([buildB, buildA], { + collectEip712CanonicalTypes([buildB, buildA], inputToUserSource, { include: ["**"], exclude: [], }).sort(), @@ -554,7 +540,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { }); it("inline a dep defined in a non-included file", () => { - const buildInfo = makeBuildInfo("solc-0_8_23-aabbccdd", [ + const sources: FakeSource[] = [ { inputSourceName: "project/test/Mail.sol", userSourceName: "test/Mail.sol", @@ -576,12 +562,14 @@ describe("eip712 - collectEip712CanonicalTypes", () => { ]), ]), }, - ]); + ]; + const buildInfo = makeBuildInfo("solc-0_8_23-aabbccdd", sources); - const result = collectEip712CanonicalTypes([buildInfo], { - include: ["test/**"], - exclude: [], - }); + const result = collectEip712CanonicalTypes( + [buildInfo], + inputToUserSourceMap(sources), + { include: ["test/**"], exclude: [] }, + ); assert.deepEqual(result, [ "Mail(Person from,Person to,string contents)Person(address wallet,string name)", @@ -589,7 +577,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { }); it("inline a dep defined in a different build info", () => { - const testBuild = makeBuildInfo("solc-0_8_23-11112222", [ + const testSources: FakeSource[] = [ { inputSourceName: "project/test/Mail.sol", userSourceName: "test/Mail.sol", @@ -600,8 +588,8 @@ describe("eip712 - collectEip712CanonicalTypes", () => { ]), ]), }, - ]); - const libBuild = makeBuildInfo("solc-0_8_23-33334444", [ + ]; + const libSources: FakeSource[] = [ { inputSourceName: "project/lib/Person.sol", userSourceName: "lib/Person.sol", @@ -612,12 +600,15 @@ describe("eip712 - collectEip712CanonicalTypes", () => { ]), ]), }, - ]); + ]; + const testBuild = makeBuildInfo("solc-0_8_23-11112222", testSources); + const libBuild = makeBuildInfo("solc-0_8_23-33334444", libSources); - const result = collectEip712CanonicalTypes([testBuild, libBuild], { - include: ["test/**"], - exclude: [], - }); + const result = collectEip712CanonicalTypes( + [testBuild, libBuild], + inputToUserSourceMap(testSources, libSources), + { include: ["test/**"], exclude: [] }, + ); assert.deepEqual(result, [ "Mail(Person from,string contents)Person(address wallet,string name)", @@ -625,7 +616,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { }); it("walks transitive deps through non-included files", () => { - const buildInfo = makeBuildInfo("solc-0_8_23-55556666", [ + const sources: FakeSource[] = [ { inputSourceName: "project/test/Order.sol", userSourceName: "test/Order.sol", @@ -656,12 +647,14 @@ describe("eip712 - collectEip712CanonicalTypes", () => { ]), ]), }, - ]); + ]; + const buildInfo = makeBuildInfo("solc-0_8_23-55556666", sources); - const result = collectEip712CanonicalTypes([buildInfo], { - include: ["test/**"], - exclude: [], - }); + const result = collectEip712CanonicalTypes( + [buildInfo], + inputToUserSourceMap(sources), + { include: ["test/**"], exclude: [] }, + ); assert.deepEqual(result, [ "Order(uint256 id,Holder holder)" + @@ -671,7 +664,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { }); it("does not throw on duplicate struct names confined to non-included files", () => { - const buildInfo = makeBuildInfo("solc-0_8_23-99990000", [ + const sources: FakeSource[] = [ { inputSourceName: "project/test/Wanted.sol", userSourceName: "test/Wanted.sol", @@ -693,18 +686,20 @@ describe("eip712 - collectEip712CanonicalTypes", () => { structAst("Helper", [{ type: "uint256", name: "b" }]), ]), }, - ]); + ]; + const buildInfo = makeBuildInfo("solc-0_8_23-99990000", sources); - const result = collectEip712CanonicalTypes([buildInfo], { - include: ["test/**"], - exclude: [], - }); + const result = collectEip712CanonicalTypes( + [buildInfo], + inputToUserSourceMap(sources), + { include: ["test/**"], exclude: [] }, + ); assert.deepEqual(result, ["Wanted(uint256 x)"]); }); it("drops a selected struct when a transitive dep in a non-included file is non decodable", () => { - const buildInfo = makeBuildInfo("solc-0_8_23-aaaa9999", [ + const sources: FakeSource[] = [ { inputSourceName: "project/test/Order.sol", userSourceName: "test/Order.sol", @@ -740,28 +735,34 @@ describe("eip712 - collectEip712CanonicalTypes", () => { ], }, }, - ]); + ]; + const buildInfo = makeBuildInfo("solc-0_8_23-aaaa9999", sources); - const result = collectEip712CanonicalTypes([buildInfo], { - include: ["test/**"], - exclude: [], - }); + const result = collectEip712CanonicalTypes( + [buildInfo], + inputToUserSourceMap(sources), + { include: ["test/**"], exclude: [] }, + ); assert.deepEqual(result, []); }); it("skips build infos whose output has no sources", () => { - // Defensive: a build info output without a `sources` key must be - // silently skipped, and structs from sibling build infos must still - // be collected. + // Defensive: a build info output without a `sources` key must be silently + // skipped, and structs from sibling build infos must still be collected. + // The empty build info still includes `struct ` in its bytes so it gets + // past the byte-level fast path and exercises the no-`sources` branch. const emptyBuildInfoId = "solc-0_8_23-88888888"; const emptyBuildInfo = { _format: "hh3-sol-build-info-1", id: emptyBuildInfoId, solcVersion: "0.8.23", solcLongVersion: "0.8.23+commit.f704f362", - userSourceNameMap: {}, - input: { language: "Solidity", sources: {}, settings: {} }, + input: { + language: "Solidity", + sources: { "stub.sol": { content: "struct _Stub {}" } }, + settings: {}, + }, }; const emptyOutput = { _format: "hh3-sol-build-info-output-1", @@ -769,7 +770,7 @@ describe("eip712 - collectEip712CanonicalTypes", () => { output: {}, // no `sources` key }; - const goodBuildInfo = makeBuildInfo("solc-0_8_23-99999999", [ + const goodSources: FakeSource[] = [ { inputSourceName: "project/test/Types.sol", userSourceName: "test/Types.sol", @@ -780,7 +781,8 @@ describe("eip712 - collectEip712CanonicalTypes", () => { ]), ]), }, - ]); + ]; + const goodBuildInfo = makeBuildInfo("solc-0_8_23-99999999", goodSources); const result = collectEip712CanonicalTypes( [ @@ -791,6 +793,55 @@ describe("eip712 - collectEip712CanonicalTypes", () => { }, goodBuildInfo, ], + inputToUserSourceMap(goodSources), + { include: ["test/**"], exclude: [] }, + ); + + assert.deepEqual(result, ["Person(address wallet,string name)"]); + }); + + it("skips build infos whose bytes don't contain `struct `", () => { + // Byte-level fast path: a build info that can't define EIP-712 types + // must be skipped without parsing its output. We exercise it by handing + // in a build info whose `output` bytes would crash JSON.parse — if the + // filter is wrong, this test throws. + const skippableBuildInfoId = "solc-0_8_23-77777777"; + const skippableBuildInfo = { + _format: "hh3-sol-build-info-1", + id: skippableBuildInfoId, + solcVersion: "0.8.23", + solcLongVersion: "0.8.23+commit.f704f362", + input: { + language: "Solidity", + sources: { "Plain.sol": { content: "contract C {}" } }, + settings: {}, + }, + }; + + const goodSources: FakeSource[] = [ + { + inputSourceName: "project/test/Types.sol", + userSourceName: "test/Types.sol", + ast: sourceUnit([ + structAst("Person", [ + { type: "address", name: "wallet" }, + { type: "string", name: "name" }, + ]), + ]), + }, + ]; + const goodBuildInfo = makeBuildInfo("solc-0_8_23-66666666", goodSources); + + const result = collectEip712CanonicalTypes( + [ + { + buildInfoId: skippableBuildInfoId, + buildInfo: utf8StringToBytes(JSON.stringify(skippableBuildInfo)), + output: utf8StringToBytes("not-valid-json"), + }, + goodBuildInfo, + ], + inputToUserSourceMap(goodSources), { include: ["test/**"], exclude: [] }, ); From 6902946ba4a4aa7ff185f4456ade3cc9bcb3e093 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 15 May 2026 13:02:12 +0000 Subject: [PATCH 30/34] treat missing array length as dynamic in EIP-712 walker --- .../solidity-test/eip712/ast-walker.ts | 3 ++- .../solidity-test/eip712/ast-walker.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index 1713be018b1..ba6eae10d3b 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -289,8 +289,9 @@ export function encodeMemberType( return undefined; } + // solc omits `length` entirely for dynamic arrays; it isn't emitted as `null`. const length = typeName.length; - if (length === null) { + if (length === null || length === undefined) { return `${base}[]`; } diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts index e5677305de7..e0c7d4ba46c 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/ast-walker.ts @@ -423,6 +423,19 @@ describe("eip712 - ast-walker", () => { ); }); + it("treats a missing `length` field as a dynamic array", () => { + // Real solc output omits `length` entirely for `T[]`; it doesn't + // emit `length: null`. + assert.equal( + encodeMemberType({ + nodeType: "ArrayTypeName", + baseType: { nodeType: "ElementaryTypeName", name: "uint256" }, + typeDescriptions: { typeString: "uint256[]" }, + }), + "uint256[]", + ); + }); + it("encodes fixed-size arrays with [N] using the canonical typeString size", () => { assert.equal( encodeMemberType({ From b100ffd70696b188d9d22d0bdaea696463d86067 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 15 May 2026 13:47:50 +0000 Subject: [PATCH 31/34] require selectedNames in EIP-712 canonicalizer --- .../solidity-test/eip712/canonicalize.ts | 25 +++++++-------- .../solidity-test/eip712/canonicalize.ts | 32 +++++++++++-------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts index 8c0f9ffe9b2..50cad5bcecc 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -25,12 +25,12 @@ import { HardhatError } from "@nomicfoundation/hardhat-errors"; * constructs and propagates `None` through the dep graph so dependents are * also dropped. * - * When `selectedNames` is provided, only those names are emitted; non-selected - * structs still participate in dep resolution so cross-file deps inline correctly. + * Only names in `selectedNames` are emitted; non-selected structs still + * participate in dep resolution so cross-file deps inline correctly. */ export function canonicalizeStructs( structs: CollectedStruct[], - selectedNames?: Set, + selectedNames: Set, ): string[] { const byName = indexByName(structs, selectedNames); const knownNames = new Set(byName.keys()); @@ -39,7 +39,7 @@ export function canonicalizeStructs( const result: string[] = []; for (const struct of byName.values()) { - if (selectedNames !== undefined && !selectedNames.has(struct.name)) { + if (!selectedNames.has(struct.name)) { continue; } @@ -212,7 +212,7 @@ function transitiveDeps( */ function indexByName( structs: CollectedStruct[], - selectedNames?: Set, + selectedNames: Set, ): Map { const byName = new Map(); const fingerprintByName = new Map(); @@ -222,13 +222,10 @@ function indexByName( { firstSource: string; secondSource: string } >(); - const ordered = - selectedNames === undefined - ? structs - : [ - ...structs.filter((s) => selectedNames.has(s.name)), - ...structs.filter((s) => !selectedNames.has(s.name)), - ]; + const ordered = [ + ...structs.filter((s) => selectedNames.has(s.name)), + ...structs.filter((s) => !selectedNames.has(s.name)), + ]; for (const struct of ordered) { const fingerprint = fingerprintStruct(struct); @@ -245,7 +242,7 @@ function indexByName( continue; } - if (selectedNames !== undefined && !selectedNames.has(struct.name)) { + if (!selectedNames.has(struct.name)) { if (!deferredConflicts.has(struct.name)) { deferredConflicts.set(struct.name, { firstSource: sourceByName.get(struct.name) ?? "", @@ -265,7 +262,7 @@ function indexByName( ); } - if (selectedNames !== undefined && deferredConflicts.size > 0) { + if (deferredConflicts.size > 0) { const reachable = reachableFromSelected(byName, selectedNames); for (const [name, sources] of deferredConflicts) { if (reachable.has(name)) { diff --git a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts index e33a4839b44..ed07c208173 100644 --- a/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts +++ b/packages/hardhat/test/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -23,9 +23,13 @@ function struct( }; } +function canonicalizeAll(structs: CollectedStruct[]): string[] { + return canonicalizeStructs(structs, new Set(structs.map((s) => s.name))); +} + describe("eip712 - canonicalize", () => { it("emits a single head for a primitives-only struct", () => { - const result = canonicalizeStructs([ + const result = canonicalizeAll([ struct("Person", [ ["address", "wallet"], ["string", "name"], @@ -48,7 +52,7 @@ describe("eip712 - canonicalize", () => { ]), ]; - const result = canonicalizeStructs(collected); + const result = canonicalizeAll(collected); assert.deepEqual(result, [ "Mail(Person from,Person to,string contents)Person(address wallet,string name)", @@ -73,7 +77,7 @@ describe("eip712 - canonicalize", () => { ]), ]; - const result = canonicalizeStructs(collected); + const result = canonicalizeAll(collected); // Transaction with deps inlined alphabetically: Asset before Person. assert.deepEqual(result, [ @@ -100,7 +104,7 @@ describe("eip712 - canonicalize", () => { struct("Wallet", [["address", "addr"]]), ]; - const result = canonicalizeStructs(collected); + const result = canonicalizeAll(collected); assert.deepEqual(result, [ "Transaction(Asset asset,Person person)" + @@ -129,7 +133,7 @@ describe("eip712 - canonicalize", () => { ), ]; - const result = canonicalizeStructs(collected); + const result = canonicalizeAll(collected); assert.deepEqual(result, ["Person(address wallet,string name)"]); }); @@ -137,7 +141,7 @@ describe("eip712 - canonicalize", () => { it("throws on conflicting definitions for the same struct name", () => { assertThrowsHardhatError( () => - canonicalizeStructs([ + canonicalizeAll([ struct("Foo", [["uint256", "a"]], "test/A.sol"), struct("Foo", [["uint256", "b"]], "test/B.sol"), ]), @@ -157,7 +161,7 @@ describe("eip712 - canonicalize", () => { // from the output even though an encodable definition exists. assertThrowsHardhatError( () => - canonicalizeStructs([ + canonicalizeAll([ struct( "Foo", [ @@ -182,7 +186,7 @@ describe("eip712 - canonicalize", () => { // is dropped, but the structs are clearly different definitions. assertThrowsHardhatError( () => - canonicalizeStructs([ + canonicalizeAll([ struct( "Foo", [ @@ -235,7 +239,7 @@ describe("eip712 - canonicalize", () => { // Both copies are non decodable (mapping member), so the canonical output is // empty — the important part is that canonicalization doesn't throw. - const result = canonicalizeStructs(collected); + const result = canonicalizeAll(collected); assert.deepEqual(result, []); }); @@ -244,7 +248,7 @@ describe("eip712 - canonicalize", () => { // Matches forge: `resolve_struct_eip712` returns `None` when any member // has an unsupported type, so the struct is filtered out entirely rather // than emitted with the bad member silently removed. - const result = canonicalizeStructs([ + const result = canonicalizeAll([ struct("Holder", [ ["uint256", "id"], [undefined, "balances"], @@ -259,7 +263,7 @@ describe("eip712 - canonicalize", () => { // Holder has a mapping → non-decodable. // Order references Holder → non-decodable too (None propagates through deps). // Person is independent → still encodable. - const result = canonicalizeStructs([ + const result = canonicalizeAll([ struct("Person", [ ["address", "wallet"], ["string", "name"], @@ -278,7 +282,7 @@ describe("eip712 - canonicalize", () => { }); it("treats array-of-struct members as a struct dep", () => { - const result = canonicalizeStructs([ + const result = canonicalizeAll([ struct("Bag", [["Item[]", "items"]]), struct("Item", [["uint256", "id"]]), ]); @@ -290,7 +294,7 @@ describe("eip712 - canonicalize", () => { }); it("strips fixed-size and nested array suffixes when resolving deps", () => { - const result = canonicalizeStructs([ + const result = canonicalizeAll([ struct("Bag", [ ["Item[3]", "fixed"], ["Other[2][3]", "nested"], @@ -311,7 +315,7 @@ describe("eip712 - canonicalize", () => { it("ignores self-references when computing deps", () => { // `S` has a member of type `S[]` (legal in Solidity). The self-ref must // not be emitted as a dep — only its name appears in the head. - const result = canonicalizeStructs([struct("S", [["S[]", "children"]])]); + const result = canonicalizeAll([struct("S", [["S[]", "children"]])]); assert.deepEqual(result, ["S(S[] children)"]); }); From 1101e0cea38e4f6c688e22756756df2d0aa5e64e Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 15 May 2026 14:16:20 +0000 Subject: [PATCH 32/34] simplify array-suffix stripping in EIP-712 dep walker --- .../solidity-test/eip712/canonicalize.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts index 50cad5bcecc..a0b1ab583a5 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -139,16 +139,7 @@ function directStructDeps( } // Strip array suffixes: `Foo[]`, `Foo[3]`, `Foo[3][2]` → `Foo`. - let base = member.type; - while (true) { - const match = /^(.*)\[[^\]]*\]$/.exec(base); - - if (match === null) { - break; - } - - base = match[1]; - } + const base = member.type.split("[")[0]; if (knownStructNames.has(base) && base !== struct.name) { deps.add(base); From cf58e3a7551749fd1df022b0e0b3462055418140 Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 15 May 2026 14:34:05 +0000 Subject: [PATCH 33/34] remove redundant canonical-string dedup in EIP-712 canonicalizer --- .../builtin-plugins/solidity-test/eip712/canonicalize.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts index a0b1ab583a5..9b477b43b18 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -35,7 +35,6 @@ export function canonicalizeStructs( const byName = indexByName(structs, selectedNames); const knownNames = new Set(byName.keys()); const encodable = computeEncodable(byName, knownNames); - const seen = new Set(); const result: string[] = []; for (const struct of byName.values()) { @@ -57,11 +56,7 @@ export function canonicalizeStructs( deps.sort(); - const canonical = head + deps.join(""); - if (!seen.has(canonical)) { - seen.add(canonical); - result.push(canonical); - } + result.push(head + deps.join("")); } return result; From 382df0912c917a31d44344cfab41cdc9e8479b8c Mon Sep 17 00:00:00 2001 From: ChristopherDedominici <18092467+ChristopherDedominici@users.noreply.github.com> Date: Fri, 15 May 2026 15:07:44 +0000 Subject: [PATCH 34/34] precompute struct deps in EIP-712 encodable loop --- .../solidity-test/eip712/canonicalize.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts index 9b477b43b18..b8bc09668fa 100644 --- a/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts +++ b/packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/canonicalize.ts @@ -80,16 +80,16 @@ function computeEncodable( } } + const depsByName = new Map(); + for (const [name, struct] of byName) { + depsByName.set(name, directStructDeps(struct, knownNames)); + } + let changed = true; while (changed) { changed = false; for (const name of [...encodable]) { - const struct = byName.get(name); - if (struct === undefined) { - continue; - } - - const deps = directStructDeps(struct, knownNames); + const deps = depsByName.get(name) ?? []; if (deps.some((dep) => !encodable.has(dep))) { encodable.delete(name); changed = true;