Hardhat's JS-side EIP-712 pipeline (builtin-plugins/solidity-test/eip712/) prepares the eip712CanonicalTypes config field for EDR. Its own logic costs milliseconds, but it parses entire build-info outputs — the AST-bearing JSON for all compiled sources — to extract a handful of struct definitions.
Measured in the Hardhat 3 + EDR profiling campaign on aave-v4's hardhat test solidity run, the only benchmark suite configuring EIP-712 types:
- JS side: 1.35 s of
parseJsonBytes under the collector — 1.21 s ≈ 37% of active JS ≈ 6% of wall
- EDR side: ~1.1 s in its native EIP-712 collector on the same run
- Combined: ~2.3 s ≈ 12% of that 19 s test run
Three structural inefficiencies: the parse is a superset (full ASTs, bytecode and metadata materialised for a few struct definitions); the result is recomputed on every test run even though build infos are immutable per build id; and the artifact loader may read the same build infos in the same run. Cost is zero when unconfigured — collection short-circuits on an empty include.
Direction
In order of likely payoff: cache the collected canonical types by build id plus EIP-712 config (build infos are content-addressed, so invalidation is trivial and warm runs pay ~0); or collect at compile time, where the output JSON is already in memory, storing a small artifact that test runs load instead of walking ASTs; or, if parsing must stay on the test path, share the parsed build info with the artifact loader rather than parsing twice.
Note: this collector is being moved to Rust (EDR feat/eip712-collector-crate-and-inline-config), including compile-time collection — so the remaining JS-side scope here may reduce to caching and shared parsing.
Implementation plan
# Stop re-parsing whole build infos in the EIP-712 canonical-type collector
## Problem
[Profiling Hardhat 3 e2e scenarios](https://app.notion.com/p/nomicfoundation/Runtime-Profiling-2026-08-05-3b3578cdeaf5808eafdff4e22ba425d0?source=copy_link) measures the JS-side EIP-712 canonical-type collection on aave-v4's `hardhat test solidity` run at 1.35 s of `parseJsonBytes` self-time under `collectEip712CanonicalTypes` — 1.21 s ≈ 37% of active JS ≈ **6% of wall**. EDR spends a further ~1.1 s in its native EIP-712 collector on the same run, so EIP-712 preparation totals ~2.3 s ≈ **12% of that 19 s test run**.
The collector's own logic costs milliseconds; the cost is the parsing. It is zero in scenarios that configure no EIP-712 types, since collection short-circuits on an empty `include`.
## Root cause
`packages/hardhat/src/internal/builtin-plugins/solidity-test/eip712/index.ts` — `collectEip712CanonicalTypes` parses **entire build-info output files** (`SolidityBuildInfoOutput`, the AST-bearing JSON for every compiled source) with `parseJsonBytes`, walks each source's AST (`ast-walker.ts`) and canonicalises struct definitions (`canonicalize.ts`). Three structural inefficiencies:
1. **Superset parsing** — the document carries full ASTs, bytecode and metadata for every source, while the collector needs only struct definitions and user-defined value types from selected sources. `JSON.parse` is all-or-nothing, so everything is materialised. A `bytesIncludesUtf8String` pre-filter exists; check what it actually filters out here.
2. **Recomputed every run** — build-info outputs are immutable per build id, yet canonical types are re-collected on every `hardhat test solidity` invocation.
3. **Duplicate parsing** — the artifact loading path may read the same build info in the same run; check `edr-artifacts.ts` (`BuildInfoAndOutput`) for overlap.
## Task
1. Reproduce the baseline (see Verification), and add a temporary timing log around `collectEip712CanonicalTypes` for aave-v4.
2. Evaluate, in order of likely payoff:
- **Cache by build id**: persist the collected canonical types keyed by build id plus EIP-712 config, next to the build info or in the cache directory. Build infos are content-addressed, so invalidation is trivial, and warm test runs pay ~0.
- **Collect at compile time**: compute the canonical types once at the end of compilation, where the output JSON is already in memory, and store them as a small artifact — test runs then load a tiny file instead of walking ASTs. Weigh the cost for users who never run EIP-712 Solidity tests; the config gate can decide.
- **Cheaper extraction**, if parsing must stay on the test path: share the parsed build-info object with the artifact loader when both need it in one run, or extract only the `ast` subtrees of selected sources — worthwhile only if measurements justify the complexity.
3. Behaviour must be identical: same canonical type strings, same `include`/`exclude` semantics, same cross-file dependency inlining (non-selected sources still feed the dependency graph — see the doc comment in `index.ts`).
4. Extend the existing eip712 unit tests with cache-hit and invalidation cases if caching lands. Run `pnpm lint`, `pnpm build`, `pnpm test` in `packages/hardhat`.
## Verification (before/after)
Profile before and after with `pnpm profiler` (a bare `pnpm profiler` prints its usage; see `scripts/README.md`):
```bash
pnpm build
pnpm profiler --scenario ./end-to-end/aave-v4 \
--prepare "cold compile" --command "test solidity" \
--mode js --init --use-local
```
Expect: `parseJsonBytes` gone from the top functions of the `.cpuprofile` — on the second, cache-warm run if caching lands, or on every run if compile-time collection does. Test results must be identical, and so must the canonical types handed to EDR (assert against a baseline dump).
Hardhat's JS-side EIP-712 pipeline (
builtin-plugins/solidity-test/eip712/) prepares theeip712CanonicalTypesconfig field for EDR. Its own logic costs milliseconds, but it parses entire build-info outputs — the AST-bearing JSON for all compiled sources — to extract a handful of struct definitions.Measured in the Hardhat 3 + EDR profiling campaign on aave-v4's
hardhat test solidityrun, the only benchmark suite configuring EIP-712 types:parseJsonBytesunder the collector — 1.21 s ≈ 37% of active JS ≈ 6% of wallThree structural inefficiencies: the parse is a superset (full ASTs, bytecode and metadata materialised for a few struct definitions); the result is recomputed on every test run even though build infos are immutable per build id; and the artifact loader may read the same build infos in the same run. Cost is zero when unconfigured — collection short-circuits on an empty
include.Direction
In order of likely payoff: cache the collected canonical types by build id plus EIP-712 config (build infos are content-addressed, so invalidation is trivial and warm runs pay ~0); or collect at compile time, where the output JSON is already in memory, storing a small artifact that test runs load instead of walking ASTs; or, if parsing must stay on the test path, share the parsed build info with the artifact loader rather than parsing twice.
Note: this collector is being moved to Rust (EDR
feat/eip712-collector-crate-and-inline-config), including compile-time collection — so the remaining JS-side scope here may reduce to caching and shared parsing.Implementation plan