# Migrating to Hardhat 3 - Part 2: Deployment Migration
## 1. Introduction
In [Part 1](https://github.com/1inch/merkle-distribution/blob/hardhat-3/articles/hardhat-v3-migration-part1.md), we covered the foundation of migrating to Hardhat 3: ES module configuration, dependency updates, hardhat.config.ts changes, and test file migration.
In this second part, we tackle deployment migration - specifically, replacing `hardhat-deploy` with Hardhat Ignition. This turned out to be one of the more significant changes in our migration, as `hardhat-deploy` (the plugin we were using) is not compatible with Hardhat 3.
We'll cover:
- Why hardhat-deploy doesn't work and what replaces it
- Creating Ignition modules
- Writing deploy scripts with the new API
- Undocumented behaviors we discovered along the way
- Testing deployments
## 2. The hardhat-deploy Problem
Our project previously used `hardhat-deploy` for contract deployments. This popular plugin provided:
- Named deployments with automatic artifact storage
- Network-specific deployment folders (`deployments/mainnet/`, `deployments/base/`, etc.)
- Integration with named accounts
- Deployment scripts with a familiar pattern
**The problem:** `hardhat-deploy` is not compatible with Hardhat 3. The plugin relies on Hardhat 2's internal APIs.
We attempted to use `hardhat-deploy@next` - an experimental version intended for Hardhat 3 support - but couldn't get it to work with the latest Hardhat release.
This led us to **Hardhat Ignition** - the official deployment system built into Hardhat 3. Beyond being the supported replacement, Ignition brings major new features:
- Declarative module-based deployments
- Automatic transaction batching and parallelization
- Built-in recovery from failed deployments
- Transaction simulation before execution
- Deployment visualization and status tracking
While migrating requires rewriting deploy scripts, Ignition is actively maintained by Nomic Foundation and designed specifically for Hardhat 3's architecture.
## 3. Setting Up Hardhat Ignition
To use Ignition, add the plugin to your configuration.
**Install dependencies:**
```bash
yarn add -D @nomicfoundation/hardhat-ignition @nomicfoundation/ignition-core
```
**hardhat.config.ts** - add Hardhat Ignition plugin to your config.
```typescript
import hardhatIgnition from '@nomicfoundation/hardhat-ignition';
const plugins: HardhatPlugin[] = [
// ... other plugins
hardhatIgnition
];
```
**tsconfig.json** - add the ignition folder to the include section:
```diff
{
"include": [
"src/**/*",
"test/**/*",
+ "ignition/**/*",
"hardhat.config.ts"
],
}
```
Without this change, VS Code will show errors when you try to reference ignition modules from other scripts.
Once registered, the plugin adds an `ignition` property to network connections, which you will use for deploying modules.
## 4. Creating Ignition Modules
Ignition uses a declarative module pattern. Instead of imperative deploy scripts, you define *what* to deploy, and Ignition handles *how*.
Create modules in the `ignition/modules/` directory:
**ignition/modules/signature.ts:**
```typescript
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
export default buildModule("SignatureDrop", (m) => {
const drop = m.contract("SignatureMerkleDrop128", [
m.getParameter<string>('token'),
m.getParameter<string>('merkleRoot'),
m.getParameter<number>('merkleHeight'),
]);
return { drop };
});
```
Key concepts:
- **`buildModule(name, callback)`** - Creates a named module. The name is used to identify this module in deployments.
- **`contract(name, args)`** - Declares a contract deployment. Arguments can be static values or parameters.
- **`getParameter<T>(name)`** - Declares a parameter that must be provided at deploy time. This keeps your modules reusable across different deployments.
- **`Return object`** - Exposed contracts that can be used by other modules or accessed after deployment.
Now the contract can be deployed running
```bash
yarn hardhat ignition deploy ignition/modules/signature.ts
```
It is necessary to mention that parameters are passed to Ignition through parameters `.json` file which can be specified with `--parameters` argument.
Our contract is relatively simple with just a single deployment. Ignition is capable of much more - you can program complex multi-contract deployment scenarios with dependencies, call existing contracts, and orchestrate entire protocol deployments. However, these complex scenarios are limited to single-repo deployments - Ignition doesn't support cross-repo deployment coordination.
## 5. Writing Deploy Scripts
In our project, deployment parameters (version, merkle root, tree height) are calculated in script execution time and come from a Hardhat task that the user runs. Ignition modules have [certain limitations](https://hardhat.org/ignition/docs/guides/scripts), for example conditional logic and async/await is prohibited.
To overcome these limitations, we wrap Ignition in a deploy script. This gives us the flexibility to process parameters from tasks and handle custom logging.
**ignition/deploy-signature.ts:**
```typescript
import hre from 'hardhat';
import SignatureDropModule from './modules/signature';
export async function deploy(version: number, merkleRoot: string, merkleHeight: number) {
const connection = await hre.network.connect();
const chainId = connection.networkConfig.chainId;
const networkName = connection.networkName;
// Load token address for this chain
const rewardTokens = (await import('./reward-tokens.json')).oneInch;
const rewardToken = rewardTokens.find((t) => t.networkId == chainId);
if (!rewardToken) {
console.log('No reward token mapped for chain', chainId);
return;
}
const { drop } = await connection.ignition.deploy(SignatureDropModule, {
parameters: {
"SignatureDrop": {
"token": rewardToken.addr,
"merkleRoot": merkleRoot,
"merkleHeight": merkleHeight
}
},
deploymentId: `${networkName}-MerkleDrop-${version}`,
});
console.log(`Deployed at address: ${drop.target}`);
return drop;
}
```
Key points:
- **`hre.network.connect()`** - Creates a connection to the network, same pattern as in tests.
- **`connection.ignition.deploy(module, options)`** - Deploys the module with parameters.
- **`parameters`** object - This is how you pass values for `m.getParameter()` calls. The object is keyed by module name, then parameter name.
>**Note:** The docs primarily show file-based parameters, but passing them as an object works and is more flexible for programmatic deployments.
- **`deploymentId`** - Unique identifier for this deployment. Ignition uses this to track deployment state and enable resumption if something fails.
Regular ignition modules are deployed with `hardhad ignition deploy` command. But since we have wrapping script we should use
```bash
yarn hardhat run ./ignition/deploy-signature.ts
```
## 6. Deployment Folder Structure
One notable difference from `hardhat-deploy`: Ignition uses a **flat deployment structure**.
With `hardhat-deploy`, we had deployments folder structured by networks where deployments happened
```
deployments/
├── mainnet/
│ ├── MerkleDrop128-2.json
│ └── MerkleDrop128-5.json
├── base/
│ └── MerkleDrop128-42.json
└── bsc/
└── MerkleDrop128-3.json
```
With Ignition, deployments are stored in `ignition/deployments` folder:
```
ignition/deployments/
├── mainnet-MerkleDrop-2/
├── mainnet-MerkleDrop-5/
├── base-MerkleDrop-42/
└── bsc-MerkleDrop-3/
```
Ignition doesn't support organizing deployments into `chainName/deploymentId` subfolders - all deployments are at the same level. The network name is encoded in the `deploymentId` instead.
Here's what each deployment folder looks like, using our project as an example:
```
ignition/deployments/sepolia-MerkleDrop-78/
├── artifacts/
│ └── SignatureDrop#SignatureMerkleDrop128.json
├── build-info/
│ └── solc-0_8_23-....json
├── deployed_addresses.json
└── journal.jsonl
```
It contains
- `artifacts/` folder - compiled contract artifacts (ABI, bytecode, source info)
- `build-info/` folder - Solidity compiler input/output for reproducible builds
- `deployed_addresses.json` - contract addresses keyed by future ID
- `journal.jsonl` - line-delimited JSON log of every deployment step
## 7. Migration from hardhat-deploy
Unfortunately, we found no automated way to convert existing `hardhat-deploy` artifacts to Ignition format. The artifact structures are fundamentally different.
Our approach:
- **Keep the old `deployments/` folder** for historical reference and reading existing deployment addresses
- **Use Ignition for all new deployments** going forward
- **Implement auto-discovery logic** in tasks to determine whether a deployment comes from the old (`hardhat-deploy`) or new (Ignition) format, and read from the appropriate location
Another challenge: with `hardhat-deploy`, the `deployments` object provided convenient methods to load and read deployment files. Ignition doesn't offer an equivalent - we now have to parse deployment JSON files manually in our tasks.
## 8. Testing Deployments
Before testing deployments, configure your networks in `hardhat.config.ts`:
```typescript
import { configDotenv } from 'dotenv';
networks: {
hardhat: {
type: 'edr-simulated',
chainId: 31337,
},
localhost: {
type: 'http',
url: 'http://localhost:8545',
chainId: 31337,
},
base: {
type: 'http',
url: configDotenv().parsed?.BASE_RPC_URL || 'https://base.drpc.org',
chainId: 8453,
accounts: [configDotenv().parsed?.BASE_PRIVATE_KEY || ''],
}
},
```
Key points:
- **`type` property** - Hardhat 3 requires explicitly specifying the network type (`'edr-simulated'` for in-memory or `'http'` for RPC connections)
- **`accounts` array** - Load private keys from environment variables for production deployments. Store them in a `.env` file and use `dotenv` to load them.
To test your deploy scripts locally:
```bash
yarn hardhat run ./ignition/deploy-signature.ts --network localhost
```
Make sure you have a node running with `yarn hardhat node` first.
## 9. Contract Verification
After deploying a contract, you typically want to verify its source code on a block explorer like Etherscan. Hardhat 3 uses the `@nomicfoundation/hardhat-verify` plugin (v3) for this.
### 9.1 Plugin Setup
We already added `@nomicfoundation/hardhat-verify` v3 as part of the dependency updates in [Part 1](/articles/hardhat-v3-migration-part1). If you don't have it installed yet:
```bash
yarn add -D @nomicfoundation/hardhat-verify
```
**hardhat.config.ts:**
```typescript
import hardhatVerify from "@nomicfoundation/hardhat-verify";
const plugins: HardhatPlugin[] = [
// ... other plugins
hardhatVerify,
];
export default defineConfig({
plugins,
// ...
verify: {
etherscan: {
apiKey: configDotenv().parsed?.ETHERSCAN_API_KEY || '',
},
blockscout: {
enabled: false,
},
sourcify: {
enabled: false,
},
}
});
```
Note the config structure change from Hardhat 2: the Etherscan API key now lives under `verify.etherscan.apiKey` instead of the top-level `etherscan` object. Blockscout and Sourcify are enabled by default - disable them explicitly if you only want Etherscan verification.
### 9.2 Build Profiles and `evmVersion`: The Verification Pitfall (Watch Out!)
After setting up verification we struggled with verification failing after deployment. Verification failed with error:
```
Fail - Unable to verify. Compiled contract deployment bytecode
does NOT match the transaction deployment bytecode.
```
The exact same contract, compiler version, and optimizer settings that worked perfectly with Hardhat 2 suddenly produced bytecode mismatches. Two issues were at play:
**Issue 1: Build profiles.** Hardhat 3 introduces [build profiles](https://hardhat.org/docs/guides/writing-contracts/build-profiles) - a new concept that doesn't exist in Hardhat 2:
- **`default`** - used by most tasks (compile, test)
- **`production`** - used by Hardhat Ignition for deployments, with its own defaults (optimizer enabled, [Isolated Builds](https://hardhat.org/docs/guides/writing-contracts/isolated-builds) enabled)
When you define your Solidity config without explicit profiles, **you're only configuring the `default` profile**. The `production` profile keeps its own defaults - which may differ from your settings (e.g., optimizer `runs` defaults to 200, not your configured 1,000,000).
Here's the sequence that causes the failure:
1. Ignition deploys using the `production` profile → bytecode compiled with production defaults
2. `verifyContract` reads build artifacts from the `default` profile → sends different compilation settings to Etherscan
3. Etherscan recompiles with those different settings → bytecode doesn't match → verification fails
**Issue 2: `evmVersion` default.** If you were relying on an explicit `evmVersion` in your Hardhat 2 config (as we were with `'shanghai'`), make sure to carry it over. Hardhat's default `evmVersion` is `paris`, not the solc default of `shanghai` for 0.8.23+. The difference is significant - `shanghai` uses the `PUSH0` opcode while `paris` doesn't, producing entirely different bytecode.
**The fix:** Explicitly configure both profiles with identical settings, including `evmVersion`:
```diff
export default defineConfig({
solidity: {
- version: '0.8.23',
- settings: {
- optimizer: {
- enabled: true,
- runs: 1000000,
- },
- },
+ profiles: {
+ default: {
+ version: '0.8.23',
+ settings: {
+ optimizer: {
+ enabled: true,
+ runs: 1000000,
+ },
+ evmVersion: 'shanghai',
+ },
+ },
+ production: {
+ version: '0.8.23',
+ settings: {
+ optimizer: {
+ enabled: true,
+ runs: 1000000,
+ },
+ evmVersion: 'shanghai',
+ },
+ },
+ },
npmFilesToBuild: ["@1inch/solidity-utils/contracts/mocks/TokenMock.sol"],
},
});
```
### 9.3 Programmatic Verification
To verify from a deploy script (useful when deployment is triggered by a Hardhat task), use the `verifyContract` function exported by the `@nomicfoundation/hardhat-verify` plugin:
```typescript
import hre from 'hardhat';
import { verifyContract } from "@nomicfoundation/hardhat-verify/verify";
export async function deploy(version: number, merkleRoot: string, merkleHeight: number) {
const connection = await hre.network.connect();
const chainId = connection.networkConfig.chainId;
const constructorArgs: [string, string, number] = [rewardToken.addr, merkleRoot, merkleHeight];
const { drop } = await connection.ignition.deploy(SignatureDropModule, {
parameters: { /* ... */ },
deploymentId: `${connection.networkName}-MerkleDrop-${version}`,
});
// Verify on Etherscan (skip for local networks)
if (chainId !== 31337) {
await verifyContract({
address: drop.target.toString(),
constructorArgs: constructorArgs,
}, hre);
}
}
```
### 9.4 CLI Verification
You can also verify after deployment using the CLI:
```bash
yarn hardhat ignition verify deploymentId
```
Deployment artifacts for the `deploymentId` already contain the chain and contracts to verify, so you don't need to pass chain id.
## 10. What's Next
In this second part, we covered the deployment side of migrating to Hardhat 3:
- Replacing `hardhat-deploy` with Hardhat Ignition
- Creating declarative Ignition modules and deploy scripts
- Deployment folder structure differences
- Migrating existing deployment artifacts
- Contract verification setup, including the build profiles pitfall
- Programmatic and CLI verification workflows
In **Part 3: Making Tasks Work with Hardhat 3**, we'll cover migrating Hardhat tasks to the new v3 task system, including:
- The new task definition syntax
- Accessing network connections from tasks
- Integrating tasks with Ignition deploy scripts