Migrate Foundry project to Hardhat 3
If the argument is update, skip to the Update an existing migration section below.
The goal is to get Hardhat compilation and Solidity tests passing, identify any blockers and missing features, and leave the user with a clear cleanup checklist for the original Foundry files. This involves config mapping, dependency conversion, import fixes, compilation, and test verification.
Important: Identifying blockers and missing Hardhat features relative to Forge is a primary goal of this migration. Do not overlook or guess at feature mappings. If a Forge feature has no clear Hardhat equivalent, do not silently skip it — leave a TODO comment in the config or code, link to a tracking issue if one exists, and flag it in the final migration report.
Foundry file preservation: Do NOT delete foundry.toml, foundry.lock, remappings.txt, lib/, or .gas-snapshot during the migration. The user needs them to cross-check results against Forge and roll back if needed. The final migration report contains a Foundry cleanup checklist that lists which files the user can delete once they've verified Hardhat works end-to-end — the user actions that checklist manually.
For package.json scripts: replace the convertible forge-based scripts (test, build, coverage, snapshot, snapshot:check) with their Hardhat equivalents under the same script name. Leave Forge-only scripts intact (e.g., forge script, forge bind, forge verify-contract) — they continue to work as long as Foundry is installed, and the report lists them as Foundry-only gaps the user must address before deleting foundry.toml.
Follow these steps in order. Do not skip ahead — each step depends on the previous one succeeding.
Step 1: Analyze the Foundry project
Before making changes, read and understand the existing project thoroughly:
- Read
foundry.toml— note ALL configured profiles and settings. Do not overlook any section. - Read
remappings.txtif it exists - Check for git submodules in
lib/— runls lib/and read.gitmodulesif present. Ensure submodules are initialized — rungit submodule update --init --recursive. Compilation will fail if submodule directories are empty. - Identify the contract source directory (usually
src/orcontracts/) - Identify the test directory (usually
test/ortests/) - Identify any scripts in
script/ - Scan
.t.solfiles for inline test config —forge-config:directives in both line comments (/// forge-config:,// forge-config:) and block comments (/** forge-config: ... */). Since Hardhat 3.3.0, inline config is supported at the function level andforge-config:is backwards-compatible (docs). Hardhat 3.5.0 / EDR0.12.0-next.33added function-level support forisolateandevm_version(edr#1349 closed). However, Hardhat only supports function-level inline config — contract-level inline config (directives placed on the contract definition rather than individual functions) is NOT supported. For contract-level configs, the workaround is to set the value globally inhardhat.config.ts. Flag any files using contract-level directives. - Scan
package.jsonscripts — read every script entry and identify all Forge-dependent commands (not justforge test/forge build). Custom scripts usingforge bind,forge script,forge snapshot,forge verify-contract, etc. represent additional Forge features the project actively uses. List them in the analysis file — they must appear in the feature parity table. - Scan for zkSync-specific profiles and contracts: Check
foundry.tomlfor zkSync profiles (e.g.,[profile.zksync]) and scan for zkSync-specific contract directories (contracts/zkSync/,contracts/zk*/,src/zkSync/, etc.). Assess what percentage of the codebase these contracts represent (file count relative to total.solfiles). zkSync compilation settings and contracts may not be testable under Hardhat — flag them for the migration report. - Determine the package manager: Check which lockfile exists (
yarn.lock,package-lock.json,pnpm-lock.yaml) and note the corresponding tool (yarn,npm,pnpm). If no lockfile exists, usepnpmas the default. If multiple lockfiles exist, pick the one that is more recently modified OR has more dependencies defined — whichever gives the clearest signal. Check modification timestamps withls -lt yarn.lock package-lock.json pnpm-lock.yaml 2>/dev/nulland line counts withwc -l. Document the choice and the reason in the analysis file. Do not introduce a second lockfile. Lock in this choice — use it for every install command throughout the session (includingpatch-packagesteps). Never mix package managers.
IMPORTANT: Migrating all configurations is KEY to the migration succeeding. Every section of foundry.toml must be accounted for — either mapped to a Hardhat equivalent, or explicitly documented as unsupported with a comment and issue link.
After analyzing, write a structured summary to a file named hardhat-migration/<project-name>-foundry-migration-analysis.md (where <project-name> is the directory name of the project being migrated) in the repository root. Create the hardhat-migration/ directory if it doesn't exist. The file includes:
- Every
foundry.tomlsection and setting found - All profiles (
[profile.default],[profile.ci], etc.) and their settings - Remappings, submodules, source/test/script directories
- Any notable patterns (absolute imports, unusual configs)
- Inline test config usage (
forge-config:comments) — list affected files and which inline settings are used, noting whether each directive is at the function level (supported in Hardhat 3.5.0+, includingisolate/evm_versionper edr#1349) or contract level (NOT supported in Hardhat — must be set globally). - All Forge-dependent
package.jsonscripts identified in step 8 above - zkSync-specific profiles and contracts identified in step 9 above (if any), with percentage of codebase affected
- Selected package manager (from step 10 above)
This file serves as a reference throughout the migration so you don't have to re-read foundry.toml repeatedly. Present the summary to the user before proceeding.
Step 2: Add Hardhat to the project
Package manager: Use the package manager determined in Step 1.
Fetch the latest Hardhat version: Run view hardhat version using the package manager determined in Step 1 (e.g. pnpm view hardhat version), and note the version string returned. If the command fails, fall back to 3.9.0.
Edit package.json:
- Add
"hardhat": "^<latest-version>"todevDependencies, using the version fetched above. - Add
"@nomicfoundation/hardhat-verify": "^3.0.11"todevDependencies(if[etherscan]section exists) - Add
"hardhat-ignore-warnings": "^0.3.0"todevDependencies(ifignored_warnings_fromis set infoundry.toml) — see "Compiler warning suppression" in Step 3 for the matching config. - Ensure the top-level field
"type": "module"is set. Note: This switches the project to ESM, which is required by Hardhat 3. If the project has existing CommonJS files (.jswithrequire()), this may break them — flag it to the user if so. - Replace the convertible
forge-based scripts (test,build,coverage,snapshot,snapshot:check) with their Hardhat equivalents under the same script name — do NOT add a parallel-hardhat-suffixed entry. The original Forge commands remain recoverable from git history. - Leave Forge-only scripts intact (e.g.,
forge script,forge bind,forge verify-contract, customforgeinvocations). They continue to work as long as Foundry is installed; the migration report lists them as Foundry-only gaps the user must address before deletingfoundry.toml. - If the project has no Forge-related scripts (no
scriptssection, or only non-Forge scripts likelint,prettier), do not add any Hardhat scripts topackage.json.
Common script replacements (replace in-place under the same key):
| Existing Foundry script | Replace with |
|---|---|
"test": "forge test" |
"test": "hardhat test solidity" |
"build": "forge build" |
"build": "hardhat compile" |
"coverage": "forge coverage ..." |
"coverage": "hardhat test solidity --coverage" |
"snapshot": "forge snapshot ..." |
"snapshot": "hardhat test solidity --snapshot" |
"snapshot:check": "forge snapshot --check ..." |
"snapshot:check": "hardhat test solidity --snapshot-check" |
Note on coverage: Hardhat 3 has built-in coverage support (no plugin needed). The --coverage flag produces LCOV (coverage/lcov.info) and HTML (coverage/html/index.html) reports. Reference: https://hardhat.org/docs/tutorial/coverage
Then install dependencies using the package manager determined above. If the project has a postinstall script that runs Forge commands (e.g., forge install), use --ignore-scripts to skip it — it's unnecessary for the Hardhat migration and may be slow or fail. For yarn: yarn install --ignore-scripts. For npm: npm install --ignore-scripts. For pnpm: pnpm install --ignore-scripts.
Finally, add Hardhat's output directories to .gitignore if not already present:
# Hardhat
artifacts/
cache/
Step 3: Create hardhat.config.ts
Create hardhat.config.ts by mapping settings from foundry.toml. Use the reference tables below for known mappings.
Config structure: Always use defineConfig()
Always use defineConfig() — it is the recommended pattern across all Hardhat 3 documentation. Do NOT use the HardhatUserConfig type annotation approach (a Hardhat 2-era pattern that still compiles but is not the documented way to configure Hardhat 3).
When using plugins (e.g. hardhat-verify), pass them in the plugins array:
import { configVariable, defineConfig } from "hardhat/config";
import hardhatVerify from "@nomicfoundation/hardhat-verify";
export default defineConfig({
plugins: [hardhatVerify],
solidity: { ... },
networks: { ... },
verify: { ... }, // typed correctly via plugin
});
When no plugins are needed:
import { defineConfig } from "hardhat/config";
export default defineConfig({
solidity: { ... },
});
Solidity compiler settings (solidity)
Map these from [profile.default] in foundry.toml:
| foundry.toml | hardhat.config.ts |
|---|---|
solc = "0.8.X" or solc_version = "0.8.X" |
solidity.compilers[0].version: "0.8.X" |
optimizer = true |
solidity.compilers[0].settings.optimizer.enabled: true |
optimizer_runs = 200 |
solidity.compilers[0].settings.optimizer.runs: 200 |
evm_version = "cancun" |
solidity.compilers[0].settings.evmVersion: "cancun" |
via_ir = true |
solidity.compilers[0].settings.viaIR: true |
bytecode_hash = "none" |
solidity.compilers[0].settings.metadata.bytecodeHash: "none" |
src = "src" |
paths.sources: "./src" |
test = "tests" |
paths.tests: "./tests" |
IMPORTANT — solidity structure rules:
- When using per-file
overrides, you MUST use thecompilersarray format. The top-levelversionfield is incompatible withoverrides. - When using
profiles, you CANNOT have top-levelcompilersorversion— all compiler config must live inside named profiles. Adefaultprofile is required.
// WRONG — overrides + version are incompatible
solidity: { version: "0.8.28", overrides: { ... } }
// WRONG — top-level compilers + profiles are incompatible
solidity: { compilers: [...], profiles: { ... } }
// CORRECT — without profiles, use compilers array with overrides
solidity: { compilers: [{ version: "0.8.28", settings: { ... } }], overrides: { ... } }
// CORRECT — with profiles, put everything inside named profiles (default is required)
solidity: { profiles: { default: { compilers: [...] }, production: { compilers: [...], overrides: { ... } } } }
Build profiles (solidity.profiles)
Foundry's [profile.*] sections, additional_compiler_profiles, and compilation_restrictions map to Hardhat's build profiles system.
When to use profiles vs plain compilers:
- Use
solidity.profileswhenfoundry.tomlhas multiple[profile.*]sections with different compiler settings (e.g.,[profile.debug]changesvia_iroroptimizer_runs). Adefaultprofile is required. - Use plain
solidity.compilers(without profiles) when there is only[profile.default]and no other profiles change compiler settings. Profiles that only override test settings (like[profile.pr.fuzz]) do not count — they have no Hardhat build profile equivalent.
Create ALL Foundry profiles that have compiler settings — do not leave any unmigrated with a TODO comment if they can be expressed in Hardhat. Every [profile.*] section with compiler settings (optimizer, viaIR, optimizer_details, etc.) must become a named Hardhat build profile.
Reference: https://hardhat.org/docs/guides/writing-contracts/build-profiles
| Foundry concept | Hardhat 3 equivalent |
|---|---|
[profile.default] base settings |
solidity.profiles.default (required when using profiles) |
additional_compiler_profiles + compilation_restrictions |
solidity.profiles.<name>.overrides with exact file paths |
[profile.coverage] compiler settings |
solidity.profiles.coverage |
Key limitations:
- Build profiles only cover compiler settings, NOT test settings (fuzz runs, etc.). Foundry profiles like
[profile.pr.fuzz]or[profile.ci.fuzz]that only change test settings have no direct equivalent — use env vars or CLI args. For settings that can't be expressed in Hardhat profiles (fuzz runs, gas snapshots, isolate, etc.), use the values from[profile.default]in the top-leveltest.solidityconfig and leave a comment explaining that the other Foundry profiles (pr, ci, coverage, gas) override these values but Hardhat profiles don't support test settings. - If a Foundry profile has both compiler and test settings (e.g.,
[profile.debug]setsvia_ir = falseANDfuzz.runs = 100), create the Hardhat build profile with only the compiler settings and add a// TODO:comment for the test settings that cannot be included. - Hardhat does NOT yet support glob patterns (
**) inoverrides. Each file must be listed individually. See: https://github.com/NomicFoundation/hardhat/issues/4686 - Leave TODO comments for glob overrides that can't be expressed.
Usage: npx hardhat compile --build-profile production
DRY principle for profiles: When multiple profiles share most compiler settings, extract the common settings into a local variable to avoid duplication:
const baseCompilerSettings = {
optimizer: { enabled: true, runs: 1000000 },
evmVersion: "shanghai" as const,
viaIR: true,
};
export default defineConfig({
solidity: {
profiles: {
default: {
compilers: [{ version: "0.8.23", settings: baseCompilerSettings }],
},
lite: {
compilers: [
{
version: "0.8.23",
settings: {
...baseCompilerSettings,
optimizer: {
...baseCompilerSettings.optimizer,
details: { yulDetails: { optimizerSteps: "" } },
},
},
},
],
},
},
},
});
Test settings (test.solidity)
Map these from [profile.default] or [fuzz]/[invariant] sections:
| foundry.toml | hardhat.config.ts |
|---|---|
fuzz.runs = 256 |
test.solidity.fuzz.runs: 256 |
fuzz.seed = "0x640" |
test.solidity.fuzz.seed: "0x640" |
fuzz.max_test_rejects = 65536 |
test.solidity.fuzz.maxTestRejects: 65536 |
fuzz.dictionary_weight = 40 |
test.solidity.fuzz.dictionaryWeight: 40 |
invariant.runs = 256 |
test.solidity.invariant.runs: 256 |
invariant.depth = 500 |
test.solidity.invariant.depth: 500 |
invariant.fail_on_revert = false |
test.solidity.invariant.failOnRevert: false |
ffi = true |
test.solidity.ffi: true |
block_gas_limit |
test.solidity.blockGasLimit |
gas_limit = N |
test.solidity.gasLimit: Nn (must be bigint, use n suffix) |
fs_permissions = [{ access = "read", path = "./file" }] |
test.solidity.fsPermissions: { readFile: ["./file"] } |
fs_permissions = [{ access = "write", path = "./file" }] |
test.solidity.fsPermissions: { writeFile: ["./file"] } |
fs_permissions = [{ access = "read-write", path = "./file" }] |
test.solidity.fsPermissions: { readWriteFile: ["./file"] } |
fs_permissions = [{ access = "read", path = "./dir" }] |
test.solidity.fsPermissions: { readDirectory: ["./dir"] } |
fs_permissions = [{ access = "write", path = "./dir" }] |
test.solidity.fsPermissions: { dangerouslyWriteDirectory: ["./dir"] } |
fs_permissions = [{ access = "read-write", path = "./dir" }] |
test.solidity.fsPermissions: { dangerouslyReadWriteDirectory: ["./dir"] } |
allow_internal_expect_revert = true |
test.solidity.allowInternalExpectRevert: true |
isolate = true |
test.solidity.isolate: true |
[bind_json] include = ["path/to/EIP712Types.sol"] |
test.solidity.eip712Types: { include: ["path/to/EIP712Types.sol"] } (Hardhat 3.5.0+) — see "EIP-712 cheatcodes" reference section |
Note on isolate: The global isolate = true in foundry.toml maps to test.solidity.isolate: true. Function-level inline /// forge-config: default.isolate = true overrides are supported as of Hardhat 3.5.0 / EDR 0.12.0-next.33 (edr#1349 closed). Contract-level inline isolate directives are still silently ignored. Do NOT set isolate globally as a workaround for contract-level usage — it dramatically slows down the entire test suite. Only set it globally if foundry.toml has isolate = true at the [profile.default] level (meaning it was already global in Forge). If only specific contracts use contract-level inline isolate, document it as a gap.
Note on inline config scope: Hardhat only supports inline forge-config: directives at the function level (on individual test functions). Foundry also supports contract-level inline config (directives placed on the contract definition, which apply to all functions in that contract). Contract-level directives are silently ignored by Hardhat.
When to apply global workarounds for unsupported inline config: Not all settings are safe to set globally. Evaluate the side effects before applying a global workaround:
- Safe to set globally:
allowInternalExpectRevert— enabling this globally is harmless; it only changes behavior whenvm.expectRevertis used on internal/library calls, and enabling it on tests that don't use that pattern has no effect. - NOT safe to set globally:
isolate— this forces each test call to run in a separate EVM context, which dramatically slows down the entire test suite. If only a few test contracts/functions useisolate, setting it globally would impose a major performance penalty on all tests. Do NOT setisolateglobally as a workaround. Instead, leave it as a documented gap in the migration report. - Use judgement for other settings:
disableBlockGasLimit, etc. — consider whether enabling them globally changes behavior for tests that don't expect it. When in doubt, do NOT set globally; document as a gap instead. (Note:evm_versionis supported inline at function level since 3.5.0; only contract-level scope or global needs would force a workaround.)
The guiding principle: only apply a global workaround if it is behaviorally neutral for tests that don't use the setting. If a global setting would change behavior or performance for unrelated tests, leave it as a gap.
fsPermissions key distinction: readFile/writeFile/readWriteFile use exact path matching (single file). readDirectory/dangerouslyWriteDirectory/dangerouslyReadWriteDirectory use prefix matching (recursive directory access). Use the directory variants when the Foundry path points to a directory.
Note: This table is not exhaustive. Hardhat 3 may support additional test settings not listed here. When encountering a Foundry test setting without a mapping in this table, check the Hardhat 3 documentation and the TypeScript type definitions in node_modules/hardhat/src/internal/builtin-plugins/solidity-test/type-extensions.ts before concluding it has no equivalent.
Network configuration (networks)
Map [rpc_endpoints] from foundry.toml. Use configVariable() for environment variables (lazy resolution — only resolved when needed).
Note: Foundry uses "${VAR_NAME}" syntax for env vars. Strip the ${} wrapper when passing to configVariable("VAR_NAME").
Tip: The chainId for each network can often be found in the [etherscan] section of the same foundry.toml.
| foundry.toml | hardhat.config.ts |
|---|---|
[rpc_endpoints] mainnet = "${RPC_MAINNET}" |
networks.mainnet: { type: "http", chainId: 1, url: configVariable("RPC_MAINNET") } |
configVariable() also supports a format parameter for URL templates:
url: configVariable(
"ALCHEMY_API_KEY",
"https://eth-mainnet.g.alchemy.com/v2/{variable}",
);
Reference: https://hardhat.org/docs/reference/configuration#network-configuration
Verification / Etherscan (verify)
Map [etherscan] from foundry.toml. In Foundry, this section configures API keys for contract verification via forge verify-contract and forge create --verify (ref: https://book.getfoundry.sh/reference/config/etherscan). Requires the @nomicfoundation/hardhat-verify plugin in Hardhat 3.
IMPORTANT: Hardhat 3 uses Etherscan API v2 which requires a single API key for all supported chains. Foundry often has per-chain keys (ETHERSCAN_API_KEY_MAINNET, etc.). Document this difference with a comment.
verify: {
etherscan: {
apiKey: configVariable("ETHERSCAN_API_KEY"),
},
},
Compiler warning suppression (warnings)
Map ignored_warnings_from from foundry.toml to the hardhat-ignore-warnings plugin. The plugin is HH3-compatible (peerDependencies: { hardhat: "^3.1.0" } as of v0.3.0). Add it to devDependencies per Step 2.
Foundry's ignored_warnings_from = ["lib/foo", "src/legacy"] silences all compiler warnings originating from the listed paths. Hardhat 3 has no built-in equivalent — the plugin provides path-keyed suppression via a top-level warnings field.
Register the plugin and translate each path into a glob key with { default: "off" }:
import { defineConfig } from "hardhat/config";
import hardhatIgnoreWarnings from "hardhat-ignore-warnings";
export default defineConfig({
plugins: [hardhatIgnoreWarnings],
solidity: { ... },
warnings: {
"lib/foo/**/*": { default: "off" },
"src/legacy/**/*": { default: "off" },
},
});
Path-to-glob conversion:
- Directory paths (e.g.,
"lib/foo") become"lib/foo/**/*"to match every file under that directory recursively. - File paths (e.g.,
"src/legacy/Old.sol") stay as-is — no glob suffix needed.
When combining with other plugins (e.g., hardhat-verify), pass them all in the plugins array.
Beyond ignored_warnings_from: The plugin also supports warning-code-specific rules (e.g., 'unused-param': 'off') and inline // solc-ignore-next-line <code> comments. Foundry has no equivalent for those — they're additional capabilities the user gains by adopting the plugin, not part of the mapping.
Foundry settings without direct Hardhat equivalents
These foundry.toml settings and commands have no direct Hardhat equivalent. Leave a comment in hardhat.config.ts for each one present in the project.
Important: Presence in this table does NOT mean the feature should be classified as 🚩 Gap in the migration report. Each row includes a Status that indicates the actual parity level — many have workarounds, partial support, or are tools that work standalone regardless of build system. Use the Status column to determine the correct parity classification.
| foundry.toml | Status |
|---|---|
dynamic_test_linking |
Foundry-only |
gas_snapshot_check / [profile.gas] |
Supported (✅ Full) — npx hardhat test solidity --snapshot / --snapshot-check; gas values match Forge. Caveat (not a gap): Hardhat's whole-suite .gas-snapshot uses a Contract#function format incompatible with Forge's Contract:function() (by design), so --snapshot-check against a committed Forge-format .gas-snapshot errors with HHE803 — regenerate the baseline with --snapshot. See Update Step 5b "KEY FACT" and hardhat#8357. Docs: https://hardhat.org/docs/guides/testing/gas-snapshots |
[bind_json] |
🟡 Partial — the include field has a direct Hardhat equivalent in test.solidity.eip712Types.include (added in Hardhat 3.5.0). Both tools require the user to point at the file(s) defining EIP-712 structs so vm.eip712HashStruct / vm.eip712HashType can resolve names. Hardhat does NOT emit a JsonBindings.sol-style helper file — if any Solidity file in the project imports schema_* constants or serialize/deserialize helpers from the generated file, that part has no Hardhat equivalent (grep for JsonBindings imports / schema_ usage to confirm before classifying). See the "EIP-712 cheatcodes" reference section below. |
[fmt] |
🟡 Partial — forge fmt works standalone regardless of build tool; prettier-plugin-solidity is a mature alternative |
[lint] |
Foundry-only (projects typically use prettier or solhint) |
forge doc |
🟡 Partial — community plugin @solarity/hardhat-markup supports HH3 for NatSpec documentation generation |
out = "out" |
Hardhat uses its own artifacts/ + cache/ dirs |
libs = ["lib"] |
Hardhat resolves lib/ deps via remappings.txt; npm deps via Node.js resolution |
[profile.zksync] / zkSync compilation (--zksync, fallback_oz, is_system, mode) |
Foundry-only — check whether @matterlabs/hardhat-zksync supports HH3 (as of early 2026 it targets HH2 only). If not, zkSync contracts compile but can't be meaningfully tested |
/// forge-config: inline test config |
🟡 Partially supported — function-level only. forge-config: prefix is backwards-compatible. Fuzz/invariant settings and allowInternalExpectRevert have worked inline at function level since Hardhat 3.3.0; isolate and evm_version were added in Hardhat 3.5.0 / EDR 0.12.0-next.33 (edr#1349 closed). Contract-level inline config (directives on contract definitions) is NOT supported in any Hardhat 3 release — use global config as workaround. See: docs |
forge coverage --ir-minimum |
Not needed — Hardhat 3's built-in --coverage handles via-IR projects natively without a separate flag. Note: coverage instrumentation may cause some tests to fail that pass without coverage — these are coverage-specific and should be investigated separately. |
Note: This table is not exhaustive. Before concluding a Foundry setting has no Hardhat equivalent, check test.solidity type definitions in node_modules/hardhat/src/internal/builtin-plugins/solidity-test/type-extensions.ts and the Hardhat 3 documentation.
IMPORTANT: Unknown settings
If you encounter foundry.toml settings that don't have a clear mapping in the tables above:
- Check if it fits under
solidity.settings(which accepts any solc input JSON option) - Check if it fits under
test.solidity - If neither — STOP and ask the user. Do not guess.
Do not assume mappings without documentation. When uncertain about what a Foundry setting does, look up the Foundry docs (https://book.getfoundry.sh/reference/config/) before mapping it to a Hardhat equivalent.
IMPORTANT: Leave comments for unsupported configs
For any foundry.toml setting that cannot be mapped due to Hardhat limitations:
- Add a
// TODO:comment inhardhat.config.tsdescribing the original Foundry setting - Include a link to the relevant Hardhat GitHub issue if one exists
- Explain the workaround (if any) or that no equivalent exists
IMPORTANT: Apply Forge defaults that differ from Hardhat
After mapping all explicit foundry.toml settings, consult the "Reference: Forge vs Hardhat 3 default values" table at the bottom of this file. For every setting where Forge's default differs from Hardhat's default, explicitly set that value in hardhat.config.ts — even if the setting is absent from foundry.toml.
For example: if foundry.toml does not set optimizer or src, Forge still defaults to optimizer = true and src = "src". Hardhat's defaults are different (optimizer off, paths.sources = "./contracts"), so these must be explicitly configured to preserve the same behavior.
Inline progress check
After finishing the config migration, briefly confirm with the user which settings were migrated, which are Foundry-only, and which have gaps — before moving on to Step 4. The full detailed report is presented in Step 7.
Step 4: Handle absolute imports
Hardhat 3 does not support absolute imports in .sol files. Scan all .sol files for absolute imports (imports that don't start with ./, ../, or a package name like @openzeppelin/).
Common patterns to look for:
import "src/SomeContract.sol";import "contracts/SomeContract.sol";import "test/helpers/SomeHelper.sol";import "lib/some-dep/SomeFile.sol";
How to find absolute imports: Absolute imports come in two Solidity syntaxes. Search for both:
- Direct imports:
import "src/..."; - Named imports:
import { Foo } from "src/...";
Use a regex like import\s.*["'](src|test|lib|contracts)/ to catch both forms and both quote styles (Solidity allows single or double quotes for import paths). Make sure to search all directories that contain .sol files, including examples/, script/, and any other non-standard directories — not just src/ and test/.
Decision rule:
Count the number of files containing absolute imports (not the number of import statements):
- ~10 or fewer files → convert them to relative imports (more compatible with all tools)
- Significantly more than 10 files → add remappings to the root
remappings.txtfile (e.g.,src/=./src/,tests/=./tests/,lib/=./lib/). This is simpler than creating scoped remappings in subdirectories and works for all files.
Note: This is a heuristic, not a strict threshold. When the count is close to the limit (e.g., 8–12 files), use your judgement. Consider the overall complexity of the changes, how many imports per file, and whether the project already uses remappings extensively. When in doubt, prefer relative imports — they are more portable and don't add implicit resolution rules.
Reference: https://hardhat.org/docs/cookbook/absolute-imports
Step 5: Compile
IMPORTANT: Only compile and test using the default build profile. Non-default profiles (production, coverage, etc.) are migrated for completeness but should NOT be compiled or tested during this migration session. They may require environment-specific tooling (e.g., native solc for viaIR) that isn't available.
Run and store the output:
npx hardhat compile --show-stack-traces 2>&1 | tee /tmp/hardhat-compile-output.txt
Do NOT run npx hardhat compile --build-profile production or any other profile.
Output caching: If /tmp/hardhat-compile-output.txt already exists, analyze whether any relevant changes have been made since it was written. If not, read the stored file instead of re-running the command. Only re-run when it is justified. When re-running, state in your response what was fixed and why the re-run is necessary before issuing the command — e.g., "Added lib/=./lib/ remapping to fix HHE902 error on absolute lib imports — re-compiling."
If compilation fails, group errors by type before presenting them to the user. Then:
- Fix import path issues, missing dependencies, or config problems
- If you see
HHE902: ... is not exported by the package, see the "npm packages with restrictiveexportsfields" reference section - If you see Solidity type mismatch errors involving types from shared dependencies, see the "Transitive dependency version conflicts" reference section
- Re-run (overwriting the cached output file) until compilation succeeds
Do not proceed to Step 6 until compilation passes cleanly.
Step 6: Run Solidity tests
Run and store the output:
npx hardhat test solidity --show-stack-traces 2>&1 | tee /tmp/hardhat-test-output.txt
Do NOT run tests with non-default build profiles.
Output caching: If /tmp/hardhat-test-output.txt already exists, analyze whether any relevant changes have been made since it was written. If not, read the stored file instead of re-running the command. Only re-run when it is justified. When re-running, state in your response what was fixed and why the re-run is necessary before issuing the command — e.g., "Commented out UnsupportedCheatcode tests — re-running to confirm remaining tests pass."
If tests fail:
- Analyze the failures — distinguish between real test failures vs migration issues
- Group errors by type before presenting them to the user
- Handle
UnsupportedCheatcodeerrors:- ONLY apply this procedure to tests whose error line reads exactly
Error: UnsupportedCheatcode: vm.xyz(...). Do NOT comment out tests that fail with any other error type — even if those errors appear migration-related. All other failures must be handled via items 4–6 below (fix or flag to the user). - For each test function that triggers an
UnsupportedCheatcodeerror, comment out the entire function (signature + body) using//line comments, and preserve the original code as commented-out lines so the disabled test remains readable. Use/* */block comments only if the function body itself contains no block comments. Prefer//line comments in all cases to avoid nesting issues. - Add the HARDHAT-SKIP header immediately before the commented-out function, e.g.:
Do NOT replace the original function body with an empty stub. An empty// HARDHAT-SKIP: This test uses vm.xyz() which is not supported by Hardhat 3 (UnsupportedCheatcode). // See: https://github.com/NomicFoundation/hardhat/issues/XXXX // function test_foo() public { // vm.xyz("some", abi.encode(param)); // assertEq(result, expected); // }test_foo()would pass trivially and give a false green — the test must not run at all. - Re-run the tests (overwriting the cached output file) to confirm remaining tests pass
- ONLY apply this procedure to tests whose error line reads exactly
- Handle suspected EDR / Hardhat bugs:
- Some test failures are neither
UnsupportedCheatcodenor fixable by editing the config — they indicate a behavioral difference or outright bug in Hardhat / EDR. These tests should not be modified, commented out, or worked around — the test code is correct Foundry-idiomatic code. - When you suspect a group of failures has the same root cause, investigate the call chain to confirm. Trace from the failing test function through any helpers or modifiers to identify exactly which cheatcode call returns unexpected results or throws unexpectedly.
- Group tests by root cause — tests failing for the same reason should be treated as a single bug, not as separate issues.
- Once the root cause is confirmed, write a structured bug report to
hardhat-migration/bugs/<descriptive-slug>.md(create thehardhat-migration/bugs/directory if needed). Use a slug that describes the affected feature or cheatcode — do NOT prefix withedr-orhardhat-(e.g.vm-readCallers-prank-state.md, notedr-vm-readCallers-prank-state.md). The responsible layer will be determined through the upstream filing process. The file should include:- Issue title suitable for filing as a GitHub issue on NomicFoundation/hardhat or NomicFoundation/edr depending on where the root cause lies
- A self-contained minimal Solidity reproduction (small test contract + minimal
hardhat.config.ts) - Steps to reproduce
- Expected vs actual behaviour (table form)
- The observed error message
- The exact failing call chain with file paths and line numbers linking into the project
- Which test(s) fail (file, contract, function name)
- Suggested fix direction for the EDR team (reference Foundry's equivalent implementation if helpful)
- The available workaround, if any — plus an explicit note stating the workaround is not applied and why (the test code is correct; applying it would mask the bug and add unnecessary boilerplate)
- This file is a draft for the user to file as a GitHub issue at NomicFoundation/hardhat or NomicFoundation/edr, depending on where the root cause lies. The skill produces the report locally; the user decides whether and where to file it upstream. The migration report references it and surfaces filing as the user's next step — do not assume an upstream issue exists yet.
- Leave the failing tests as-is in the codebase. Do not comment them out. They are genuine blockers.
- Some test failures are neither
- Fix other migration-related issues (import paths, config differences)
- For real test failures unrelated to migration, flag them to the user
- If failures persist after applying fixes, ask the user whether they want to keep iterating or proceed directly to generating a Failed verdict report
Common error patterns and fixes:
"call didn't revert at a lower depth than cheatcode call depth"or"reverted with an unrecognized custom error"on tests usingvm.expectReverton internal/library calls → enabletest.solidity.allowInternalExpectRevert: truein config (maps Foundry'sallow_internal_expect_revert)- Gas-related failures, out-of-gas reverts, or unexpected reverts in large tests → check that
test.solidity.gasLimitmatchesfoundry.toml'sgas_limit(must be a bigint withnsuffix) vm.envString: environment variable "X" not found— tests that read env vars viavm.envString()orvm.envOr()insetUp()will fail if those vars aren't set. Fix: set the required env var in the Hardhat test script inpackage.json(e.g.,"test": "MY_VAR=value hardhat test solidity")
When tests pass, always show the full test output to the user so they can verify the results themselves.
Coverage verification (optional): If the project has a forge coverage script in package.json, run npx hardhat test solidity --coverage after tests pass to verify coverage works. Coverage instrumentation can reveal issues not seen in normal test runs. Compare the number of passing tests with and without --coverage — any discrepancy should be noted in the migration report. Note: Hardhat 3's coverage handles via-IR projects natively; Forge's --ir-minimum flag has no equ
…(truncated)