On the provider (mocha) test path, most hardhat test CPU is the JS side rather than EDR, and the ethers + @noble crypto stack is its largest component. Measured in the Hardhat 3 + EDR profiling campaign: that stack is ≈24% of wall-clock on openzeppelin-contracts (56% of active JS) and ≈20% of wall on lidofinance-core.
The single hottest symbol in openzeppelin's run is keccakP from @noble/hashes inside ethers v6 — 4.3% of total CPU ≈ ~4% of wall (~9% of active JS) — the keccak permutation executing as JIT'd JavaScript. Every ethers operation hashes: getAddress() checksums, ABI selectors and topics, transaction serialisation, CREATE address derivation. Node's OpenSSL cannot substitute: it provides standardised SHA3-256, whose padding differs from Ethereum's pre-standard keccak256.
ethers v6 makes this fixable without patching: keccak256.register(fn) exists precisely so environments can install faster implementations. JS ECDSA via @noble/curves is larger still (≈5% of wall, ~11% of active JS) and its signingKey hooks are registrable the same way.
Direction
Register a native keccak256 in hardhat-ethers when the plugin materialises its ethers instance — sourced ideally from a small @nomicfoundation/edr export, since EDR already links keccak natively. Requirements: bit-identical output (verified exhaustively; a mismatch is consensus-breaking), no registration into a user's separately imported ethers copy, silent fallback where no native binary exists, and a measured check that napi call overhead does not make small inputs slower. Native signing is a sensible stretch goal once keccak proves the pattern.
Suites on viem get no benefit — viem has no substitution hook. An upstream conversation with ethers about a native or WASM default would fix it for everyone, and is a separate follow-up.
Implementation plan
# Register a native keccak256 in hardhat-ethers via ethers' crypto hooks
## Problem
[Profiling Hardhat 3 e2e scenarios](https://app.notion.com/p/nomicfoundation/Runtime-Profiling-2026-08-05-3b3578cdeaf5808eafdff4e22ba425d0?source=copy_link) shows that on the provider (mocha) path, most `hardhat test` CPU is the JS side rather than EDR, and the ethers + `@noble` crypto stack is its largest component: ≈24% of wall-clock on openzeppelin-contracts (56% of active JS), ≈20% of wall on lidofinance-core.
The single hottest symbol in openzeppelin's run is `keccakP` from `@noble/hashes` inside ethers v6 — 4.3% of total CPU ≈ **~4% of wall** (~9% of active JS) — the keccak permutation running as JIT'd JavaScript. Every ethers operation hashes: `getAddress()` checksums, ABI selectors and topics, transaction serialisation, CREATE address derivation. Node's OpenSSL cannot help: it offers standardised SHA3-256, whose padding differs from Ethereum's pre-standard keccak256.
JS ECDSA via `@noble/curves` (`mod`/`invert`/`mul`) is larger still at ≈5% of wall (~11% of active JS), and ethers exposes `signingKey` hooks the same way — a stretch goal once keccak proves the pattern.
## Root cause
ethers v6 crypto functions are explicitly substitutable: `keccak256.register(fn)` (same for `sha256`, `computeHmac`, …) exists precisely so environments can install faster implementations. Hardhat registers nothing, so the pure-JS implementations run.
## Task
1. **Pick the native keccak source**, in preference order:
- export one from `@nomicfoundation/edr` (`crates/edr_napi`): a sync `keccak256(input: Buffer | Uint8Array): Buffer` napi function — EDR already links keccak natively, so the binding is tiny, and it inherits any asm-backend work there;
- a small, well-maintained prebuilt napi package as a `hardhat-ethers` dependency (weigh maintenance and supply-chain risk; the team already ships its own napi binaries, which argues for the EDR route).
Micro-benchmark the choice against `@noble/hashes` on 32/64/128/1024-byte inputs: napi call overhead must not make small inputs slower. If it does for ≤32-byte inputs, keep JS below that size — measure, don't guess.
2. **Wire the registration in `packages/hardhat-ethers`** where the plugin materialises its ethers instance, respecting the lazy-init rules (not at `index.ts` import time — startup cost is a separate concern). Register only into the ethers instance the plugin itself resolves; never reach into a user's separately imported copy. If the native binary is missing, fall back to noble silently.
3. **Correctness gate**: exhaustive equivalence tests against `@noble/hashes` — random inputs from 0 bytes to several KB, plus empty input and >1 MB, compared byte-for-byte. Any mismatch is consensus-breaking. Confirm `isAddress`/`getAddress` checksums and typed-data hashing still match known vectors.
4. **Scope check**: confirm which ethers copy a scenario actually exercises (top-level `node_modules/ethers` vs a nested copy under `@nomicfoundation/hardhat-ethers`). If test code imports its own ethers, plugin-level registration may not reach it — document that limitation rather than monkey-patching module internals beyond the sanctioned `register()` API.
5. Run `pnpm lint`, `pnpm build`, `pnpm test` in `packages/hardhat-ethers` (plus EDR's verify set if an export was added there).
Suites on viem get no benefit — viem has no substitution hook. Upstream conversations with ethers about a native or WASM default are a separate, non-code follow-up.
## 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/openzeppelin-contracts \
--prepare "cold compile" --command "test mocha" \
--mode both --init --use-local
```
Expect: `keccakP` gone (or ≪1%) from `report.txt` and the `.cpuprofile`, replaced by a small native-hash entry; suite wall time down ~3–4%; identical test results (same pass count in `cmd.log`).
On the provider (mocha) test path, most
hardhat testCPU is the JS side rather than EDR, and the ethers +@noblecrypto stack is its largest component. Measured in the Hardhat 3 + EDR profiling campaign: that stack is ≈24% of wall-clock on openzeppelin-contracts (56% of active JS) and ≈20% of wall on lidofinance-core.The single hottest symbol in openzeppelin's run is
keccakPfrom@noble/hashesinside ethers v6 — 4.3% of total CPU ≈ ~4% of wall (~9% of active JS) — the keccak permutation executing as JIT'd JavaScript. Every ethers operation hashes:getAddress()checksums, ABI selectors and topics, transaction serialisation, CREATE address derivation. Node's OpenSSL cannot substitute: it provides standardised SHA3-256, whose padding differs from Ethereum's pre-standard keccak256.ethers v6 makes this fixable without patching:
keccak256.register(fn)exists precisely so environments can install faster implementations. JS ECDSA via@noble/curvesis larger still (≈5% of wall, ~11% of active JS) and itssigningKeyhooks are registrable the same way.Direction
Register a native keccak256 in
hardhat-etherswhen the plugin materialises its ethers instance — sourced ideally from a small@nomicfoundation/edrexport, since EDR already links keccak natively. Requirements: bit-identical output (verified exhaustively; a mismatch is consensus-breaking), no registration into a user's separately imported ethers copy, silent fallback where no native binary exists, and a measured check that napi call overhead does not make small inputs slower. Native signing is a sensible stretch goal once keccak proves the pattern.Suites on viem get no benefit — viem has no substitution hook. An upstream conversation with ethers about a native or WASM default would fix it for everyone, and is a separate follow-up.
Implementation plan