Migrating Hardhat project to ZKsync Era

Learn how to migrate an existing Hardhat project to ZKsync Era.
ZKsync supports both EVM and EraVM. This migration is only required to deploy and test EraVM contracts.

Prerequisites

  • Node.js version 18 or newer is required.
  • To ensure compatibility with our plugins, make sure that Hardhat version 2.16.0 or newer is installed in your project.

Project setup

The @matterlabs/hardhat-zksync plugin includes all the necessary tools to compile, test, deploy, and verify contracts on ZKsync.

Under the hood, this plugin bundles together several plugins that focus on specific features like compilation, deployment, and verification. You can learn more about each plugin in the Getting started section.
  1. Install the @matterlabs/hardhat-zksync plugin with:
    npm i -D @matterlabs/hardhat-zksync
    
  2. Import the plugin in the hardhat.config.ts file. If your project has multiple plugins, import @matterlabs/hardhat-zksync last:
    // Other Hardhat plugin imports above
    import '@matterlabs/hardhat-zksync';
    
  3. Remove conflicting plugin imports
    • @nomicfoundation/hardhat-ethers: @matterlabs/hardhat-zksync includes hardhat-ethers extended with support for ZKsync and non-ZKsync networks.
    • @openzeppelin/hardhat-upgrades: @matterlabs/hardhat-zksync includes hardhat-upgrades extended with support for ZKsync and non-ZKsync networks.
    • @nomicfoundation/hardhat-toolbox: this plugin includes @nomicfoundation/hardhat-ethers which can cause conflicts. Import necessary components separately excluding @nomicfoundation/hardhat-ethers.
    // import "@nomicfoundation/hardhat-toolbox"
    // import "@nomicfoundation/hardhat-ethers"
    // import "@openzeppelin/hardhat-upgrades"
    import '@matterlabs/hardhat-zksync';
    
  4. Add the preferred ZKsync networks to the hardhat.config.ts file:
      defaultNetwork: 'ZKsyncEraSepolia',
      networks: {
        ZKsyncEraSepolia: {
          url: 'https://sepolia.era.zksync.dev',
          ethNetwork: 'sepolia',
          zksync: true,
          verifyURL: 'https://explorer.sepolia.era.zksync.dev/contract_verification',
          accounts: process.env.WALLET_PRIVATE_KEY ? [process.env.WALLET_PRIVATE_KEY] : [],
        },
        ZKsyncEraMainnet: {
          url: 'https://mainnet.era.zksync.io',
          ethNetwork: 'mainnet',
          zksync: true,
          verifyURL: 'https://zksync2-mainnet-explorer.zksync.io/contract_verification',
          accounts: process.env.WALLET_PRIVATE_KEY ? [process.env.WALLET_PRIVATE_KEY] : [],
        },
        dockerizedNode: {
          url: 'http://localhost:3050',
          ethNetwork: 'http://localhost:8545',
          zksync: true,
          accounts: process.env.WALLET_PRIVATE_KEY ? [process.env.WALLET_PRIVATE_KEY] : [],
        },
        anvilZKsync: {
          url: 'http://127.0.0.1:8011',
          ethNetwork: 'http://localhost:8545',
          zksync: true,
          accounts: process.env.WALLET_PRIVATE_KEY ? [process.env.WALLET_PRIVATE_KEY] : [],
        },
        hardhat: {
          zksync: true,
        },
      },
    
  5. Add the zksolc config to specify the zksolc compiler version and codegen:
      zksolc: {
        version: '1.5.15',
        settings: {
          codegen: 'yul',
          // find all available options in the official documentation
          // https://docs.zksync.io/build/tooling/hardhat/hardhat-zksync-solc#configuration
        },
      },
    

Compilation

ZKsync Era (as well as other chains built with ZKsync Stack) is operated by the EraVM, which executes a specific bytecode that differs from the EVM. This bytecode is generated by the zksolc (for Solidity contracts) and zkvyper (for Vyper contracts) compilers.

To compile your contracts with these compilers, follow these steps:

  1. Run the compilation task targeting one of the ZKsync networks, which contain zksync: true:
    npx hardhat compile --network ZKsyncEraSepolia
    
  2. The following output indicates the contracts are being compiled with the zksolc compiler:
    Compiling contracts for ZKsync Era with zksolc v1.5.15 and zkvm-solc v0.8.30-1.0.2
    Compiling 42 Solidity files
    
  3. The compiler generates the /artifacts-zk and /cache-zk folders containing the smart contract correspondent artifacts, which follow the same structure as the ones generated by the solc compiler.

Compiler settings

You can modify different compiler settings in the zksolc or zkvyper property inside the hardhat.config.ts file.

Non-inline Libraries

Deploying non-inline libraries on ZKsync differs from Ethereum.

On Ethereum, non-inlineable libraries must be deployed beforehand, and then referenced in the deployment transaction of the contract that imports them:

Ethereum non-inlineable libraries
const firstLibrary = await hre.ethers.deployContract("LibraryA");
await firstLibrary.waitForDeployment();
const firstLibraryAddress = await firstLibrary.getAddress()

const secondLibrary = await hre.ethers.deployContract("LibraryB");
await secondLibrary.waitForDeployment();
const secondLibraryAddress = await l2.getAddress();

const mainContract = await hre.ethers.deployContract("MainContract",{
  libraries:{
    LibraryA:firstLibraryAddress,
    LibraryB:secondLibraryAddress
  }
});

await mainContract.waitForDeployment();

On ZKsync, if your project contains non-inlineable libraries, the compilation command will throw an error.

To automatically deploy all non-inlineable libraries, follow these steps:

  1. Configure a deployer account in the ZKsync network you want to deploy by adding the accounts:[] property.
  2. Run the following command to deploy the libraries and auto-generate the libraries configuration in the hardhat.config.ts file:
    npx hardhat deploy-zksync:libraries
    

    The command will add the libraries and their addresses to the hardhat.config.ts file.
  3. Now you can compile the main contract that imports the libraries and deploy the contract without the need to reference the libraries:
    deploy.ts
    import * as hre from 'hardhat';
    
    export default async function () {
      const mainContract = await hre.ethers.deployContract('Main');
      await mainContract.waitForDeployment();
      console.log('Main contract deployed to:', mainContract.target);
    }
    

Troubleshoting

Use of unsupported opcodes like SELFDESTRUCT or EXTCODECOPY. The compiler will throw an error if any unsupported opcodes is used in one of the contracts. See differences with EVM opcodes in EVM instructions.

Testing

ZKSync provides different EraVM node implementations to test smart contracts locally:

  • anvil-zksync (in-memory node implementation for ZKsync): fast L2 node with non-persistent state.
  • Dockerized setup: L1 and L2 nodes with persistent state but slower performance.

Unless your project contains L1-L2 features, testing with the anvil-zksync is recommended, which is included in the @matterlabs/hardhat-zksync plugin.

In version 1.0.12, hardhat-network-helpers introduced support for both anvil-zksync node and Dockerized setups, allowing methods such as loadFixture to be utilized in test files.

You can read more about each node in the Testing section of the docs.

Running unit tests on anvil-zksync node

To run tests using anvil-zksync node, follow these steps:

  1. Add the zksync:true flag to the hardhat network in the hardhat.config.ts file to override Hardhat's default node with anvil-zksync node.
  2. Run the test task with npx hardhat test --network hardhat (or make hardhat the default network).

You can find more info about testing with anvil-zksync in Hardhat-ZKsync node.

Running tests on Dockerized setup

To run tests on the Dockerized local setup, follow these steps:

  1. Run npx zksync-cli dev config and select the “Dockerized node” option.
  2. Run npx zksync-cli dev start to start the L1 and L2 nodes.
  3. Add the Dockerized nodes to the list of networks in the hardhat.config.ts file:
hardhat.config.ts
networks: {
    dockerizedNode: {
      url: 'http://localhost:3050',
      ethNetwork: 'http://localhost:8545',
      zksync: true,
      accounts: process.env.WALLET_PRIVATE_KEY ? [process.env.WALLET_PRIVATE_KEY] : [],
    },
  // Other networks

}
  1. Make sure the providers in your test files target the correct url.
  2. Run the test task with npx hardhat test --network dockerizedNode.

Deployment

Smart contract deployment on ZKsync Era (and chains built with ZKsync Stack) differ from Ethereum as they are handled by the ContractDeployer system contract (see Ethereum differences).

There are different approaches for contract deployment:

Deployment with hardhat-ethers

The @matterlabs/hardhat-zksync includes @matterlabs/hardhat-zksync-ethers, a package that extends @nomiclabs/hardhat-ethers with all the necessary helper methods to deploy contracts on both ZKsync and EVM networks. The injected hre.ethers object provides methods like deployContract, getContractFactory or getContractAt so deployment scripts work out of the box.

To avoid typescript collision errors between @nomiclabs/hardhat-ethers and @matterlabs/hardhat-zksync-ethers make sure that only the latter (or the wrapper plugin @matterlabs/hardhat-zksync) is imported in the hardhat.config.ts file.

See an example below:

hardhat-ethers
// Script that deploys a given contract to a network
import { ethers, network } from 'hardhat';

async function main() {
  const CONTRACT_NAME = 'Greeter';
  const ARGS = ['Hi there!'];
  console.log(`Deploying ${CONTRACT_NAME} contract to ${network.name}`);
  const contract = await ethers.deployContract(CONTRACT_NAME, ARGS, {});
  await contract.waitForDeployment();
  const contractAddress = await contract.getAddress();
  console.log(`${CONTRACT_NAME} deployed to ${contractAddress}`);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

When a custom deployment is needed, use ContractFactory.

hardhat-ethers
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const attachTo = new zk.ContractFactory<any[], Contract>(
        contractV2Artifact.abi,
        contractV2Artifact.bytecode,
        deployer.zkWallet
      );
      const attachment = attachTo.attach(campaignAddress);
      const v2Campaign = attachment.connect(deployer.zkWallet) as unknown as V2_BeaconCrowdfundingCampaign;

Deployment with hardhat-deploy

The newest versions of the hardhat-deploy plugin (beginning with 0.11.26) now support ZKsync deployments out of the box. This means you don't need to modify your deployment scripts and methods like getNamedAccounts can be used on ZKsync.

Deployment with hardhat-viem

To be included.

Deployment with Hardhat ignition

Hardhat ignition scripts do not support deployments to ZKsync Era yet. Please use other options like hardhat-deploy or hardhat-ethers

Deploying proxy contracts

The @matterlabs/hardhat-zksync includes @matterlabs/hardhat-zksync-upgradable, which extends @openzeppelin/hardhat-upgrades with all the necessary methods to deploy and upgrade proxy contracts on ZKsync and EVM networks.

The injected hre.upgrades object provides methods like deployProxyor deployBeacon so deployment scripts work out of the box.

To avoid typescript collision errors between @openzeppelin/hardhat-upgrades and @matterlabs/hardhat-zksync-upgradable make sure that only the latter (or the wrapper plugin @matterlabs/hardhat-zksync) is imported in the hardhat.config.ts file.

See examples below:

Transparent proxy (deployment)

@openzeppelin/contracts-upgradable
import { ethers, upgrades } from 'hardhat';

async function main() {
  const fundingGoal = '0.1';
  console.log('Deploying with funding goal:', fundingGoal);

  const factory = await ethers.getContractFactory('ProxyableCrowdfundingCampaign');

  // Deploy the contract using a transparent proxy
  const crowdfunding = await upgrades.deployProxy(factory, [ethers.parseEther(fundingGoal).toString()], {
    initializer: 'initialize',
  });
  await crowdfunding.waitForDeployment();
}

Transparent proxy (upgrade)

@openzeppelin/hardhat-upgrades
import { ethers, upgrades } from 'hardhat';

// Replace with your deployed transparent proxy address
const proxyAddress = process.env.TRANSPARENT_PROXY_ADDRESS ?? 'YOUR_PROXY_ADDRESS_HERE';

async function main() {
  const contractV2Factory = await ethers.getContractFactory('V2_ProxyableCrowdfundingCampaign');

  // Upgrade the proxy to V2
  const upgradedContract = await upgrades.upgradeProxy(proxyAddress, contractV2Factory);

  console.log('Successfully upgraded ProxyableCrowdfundingCampaign to V2_ProxyableCrowdfundingCampaign');

  // wait some time before the next call
  await new Promise((resolve) => setTimeout(resolve, 2000));

  // Initialize V2 with a new campaign duration
  const durationInSeconds = 30 * 24 * 60 * 60; // For example, setting a 30-day duration
  const initTx = await upgradedContract.initializeV2(durationInSeconds);
  const receipt = await initTx.wait();

  console.log(`V2_ProxyableCrowdfundingCampaign initialized. Transaction Hash: ${receipt?.hash}`);
}

Beacon proxy (deployment)

@openzeppelin/contracts-upgradable
import { ethers, upgrades } from 'hardhat';

async function main() {
  const fundingGoalInWei = ethers.parseEther('0.1').toString();

  const beaconFactory = await ethers.getContractFactory('BeaconCrowdfundingCampaign');
  const beacon = await upgrades.deployBeacon(beaconFactory);
  await beacon.waitForDeployment();

  const crowdfunding = await upgrades.deployBeaconProxy(beacon, beaconFactory, [fundingGoalInWei]);
  await crowdfunding.waitForDeployment();
}

Beacon proxy (upgrade)

@openzeppelin/hardhat-upgrades
import { ethers, upgrades } from 'hardhat';
import type { V2_BeaconCrowdfundingCampaign } from '../../../typechain-types';

// Update with the address for your beacon contract
// and the beacon proxy address
const beaconAddress = process.env.BEACON_ADDRESS ?? 'YOUR_BEACON_ADDRESS_HERE';
const proxyAddress = process.env.BEACON_PROXY_ADDRESS ?? 'YOUR_PROXY_ADDRESS_HERE';

async function main() {
  const beaconV2Factory = await ethers.getContractFactory('V2_BeaconCrowdfundingCampaign');

  // Upgrade the proxy to V2
  await upgrades.upgradeBeacon(beaconAddress, beaconV2Factory);

  console.log('Successfully upgraded BeaconCrowdfundingCampaign to V2_BeaconCrowdfundingCampaign');

  const upgradedContract = beaconV2Factory.attach(proxyAddress) as V2_BeaconCrowdfundingCampaign;

  // wait some time before the next call
  await new Promise((resolve) => setTimeout(resolve, 2000));

  // Initialize V2 with a new campaign duration
  const durationInSeconds = 30 * 24 * 60 * 60; // For example, setting a 30-day duration
  const initTx = await upgradedContract.initializeV2(durationInSeconds);
  const receipt = await initTx.wait();

  console.log(`V2_BeaconCrowdfundingCampaign initialized. Transaction Hash: ${receipt?.hash}`);
}

UUPS proxy (deployment)

@openzeppelin/contracts-upgradable
import { ethers, upgrades } from 'hardhat';

async function main() {
  const factory = await ethers.getContractFactory('UUPSCrowdfundingCampaign');
  const fundingGoalInWei = ethers.parseEther('0.1').toString();

  const crowdfunding = await upgrades.deployProxy(factory, [fundingGoalInWei], { initializer: 'initialize' });

  await crowdfunding.waitForDeployment();
}

UUPS proxy (upgrade)

@openzeppelin/hardhat-upgrades
import { ethers, upgrades } from 'hardhat';

// Replace with the address of the proxy contract you want to upgrade
const proxyAddress = process.env.UUPS_PROXY_ADDRESS ?? 'YOUR_PROXY_ADDRESS_HERE';

async function main() {
  const contractV2factory = await ethers.getContractFactory('V2_UUPSCrowdfundingCampaign');

  const upgradedContract = await upgrades.upgradeProxy(proxyAddress, contractV2factory);
  console.log('Successfully upgraded UUPSCrowdfundingCampaign to V2_UUPSCrowdfundingCampaign');

  // wait some time before the next call
  await new Promise((resolve) => setTimeout(resolve, 0));

  const durationInSeconds = 30 * 24 * 60 * 60; // For example, setting a 30-day duration

  const initTx = await upgradedContract.initializeV2(durationInSeconds);
  const receipt = await initTx.wait();

  console.log('V2_UUPSCrowdfundingCampaign initialized! Transaction Hash: ', receipt?.hash);
}

Custom deployment scripts with hardhat-zksync

Additionally, you can write custom deployment scripts for ZKsync leveraging the hre.deployer object which is injected automatically by @matterlabs/hardhat-zksync-deploy. The Deployer class provides helper methods to deploy smart contracts to ZKsync. Learn more on hardhat-zksync-deploy methods.

Troubleshoting

  • Contract size too large: if the size of the generated bytecode is too large and can not be deployed, try compiling the contract with the mode: 3 flag in the hardhat.config.ts file to optimize the bytecode size on compilation. Learn more on hardhat-zksync-solc configuration.

Smart contract verification

There are no differences in the verification of smart contracts on ZKsync.

By installing @matterlabs/hardhat-zksync, a verification plugin is provided.

You will have to add a verifyURL on the ZKsync networks in the hardhat.config.ts file:

    ZKsyncEraSepolia: {
      url: 'https://sepolia.era.zksync.dev',
      ethNetwork: 'sepolia',
      zksync: true,
      verifyURL: 'https://explorer.sepolia.era.zksync.dev/contract_verification',
      accounts: process.env.WALLET_PRIVATE_KEY ? [process.env.WALLET_PRIVATE_KEY] : [],
    },
    ZKsyncEraMainnet: {
      url: 'https://mainnet.era.zksync.io',
      ethNetwork: 'mainnet',
      zksync: true,
      verifyURL: 'https://zksync2-mainnet-explorer.zksync.io/contract_verification',
      accounts: process.env.WALLET_PRIVATE_KEY ? [process.env.WALLET_PRIVATE_KEY] : [],
    },

You can run the verification task programmatically inside a script as follows:

const verificationId = await hre.run("verify:verify", {
  address: contractAddress,
  contract: contractFullyQualifedName,
  constructorArguments: [...]
});

Alternatively you can execute the verification task from your terminal:

npx hardhat verify --network testnet 0x7cf08341524AAF292255F3ecD435f8EE1a910AbF "Hi there!"

Find more info in hardhat-zksync-verify, and in the "How to Verify Contracts" tutorial.

Scripting

Most scripts will work out of the box as interacting with smart contracts deployed on ZKsync is exactly the same as on any EVM chain.

For ZKsync-specific features like native account abstraction or paymaster transactions, there are plugins or extensions for the most popular libraries:

For other programming languages, please refer to the SDK documentation.

Multichain projects

The following example project can be used as a reference to target both EVM and ZKsync chains.

Here are some recommendations for projects that target multiple chains:

  • Add the desired ZKsync networks with the zksync:true flag to the hardhat.config.ts.
  • Make sure to run the compilation task with the --network flag when targeting ZKsync networks to use the custom compiler.
  • When targeting ZKsync chains, the @matterlabs/hardhat-zksync plugin overrides the following plugins: @nomiclabs/hardhat-ethers, @openzeppelin/hardhat-upgrades.
  • To avoid typescript collision errors between @nomiclabs/hardhat-ethers and @openzeppelin/hardhat-upgrades with @matterlabs/hardhat-zksync make sure that only the latter is imported in the hardhat.config.ts file.
  • Your deployment scripts should not require any changes if you're using hardhat-deploy or hardhat-ethers.
  • If you have a separate directory for ZKsync deployment scripts, you can indicate the custom deployment folder for the ZKsync network using the deployPaths property:
const config: HardhatUserConfig = {
  networks: {
    zksyncTestnet: {
      url: "https://sepolia.era.zksync.dev",
      ethNetwork: "sepolia",
      zksync: true,
      // ADDITION
      deployPaths: "deploy-zksync", //single deployment directory
      deployPaths: ["deploy", "deploy-zksync"], //multiple deployment directories
    },
  },
};

Support

If you're having issues migrating a Hardhat project to ZKsync, please reach out to us by creating a GitHub discussion.


Made with ❤️ by the ZKsync Community