Skip to content

EIP-712 canonical-type collection re-parses whole build infos #1663

Description

@Wodann

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions