Customizing Your ZK Chain

Create a ZK chain with a custom ERC20 base token.

One way you can customize your ZK chain is by changing the base token used for gas from ETH to an ERC20 token. Let's try customizing a new chain by using a custom ERC20 base token.

With the initial release of ZK stack, custom ERC20 tokens must be added to an allowlist for use as a base token for a chain. The allowed addresses are stored and checked in the BridgeHub contract on the L1. In the future it will be possible to deploy a chain that uses a new custom base token in a permissionless process.

For now, you have the ability to add any tokens to the allowlist in your local ecosystem.

The overall flow for setting up a local chain with a custom base token looks like this:

  1. Deploy an ERC20 token to the L1.
  2. Create your new chain that uses the ERC20 token and set it as the default.
  3. Send ERC20 tokens to the ecosystem and chain governor addresses on the L1.
  4. Initialize the new chain in the ecosystem.
  5. Bridge tokens from the L1 to your new chain.

Deploying an ERC20 token

For the first step of this flow, let's make a new hardhat project.

Setup

Move out of your previous folder and make a new folder for the ERC20 token contract.

mkdir base-token-contract
cd base-token-contract

Then, run the hardhat init command to generate a new project:

npx hardhat init

Select the option Create a Typescript project and accept the default options for everything else.

Run the command below to install the necessary dependencies:

npm i -D typescript ts-node @openzeppelin/contracts @nomicfoundation/hardhat-ethers @typechain/ethers-v6 @typechain/hardhat typechain ethers dotenv

Configuring Hardhat

Once installed, replace your existing config in hardhat.config.ts with the config below:

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

import dotenv from "dotenv";
dotenv.config();

const config: HardhatUserConfig = {
  solidity: "0.8.20",
  networks: {
        localRethNode: {
          url: "http://localhost:8545",
          accounts: [process.env.WALLET_PRIVATE_KEY as any],
        },
      },
};

export default config;

Now hardhat is configured to connect to our local reth node (the L1) running at local port 8545. For the inital release of the ZK stack, we have to deploy the token to the L1 and bridge to our ZK chain later. However, in the future this may change.

Next, create a .env file with:

touch .env

Add the WALLET_PRIVATE_KEY environment variable with the private key of the rich wallet we've been using so that we can deploy using a pre-funded address:

WALLET_PRIVATE_KEY=0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110

Deploying an ERC20 Contract

Now that we've configured hardhat and the deployer wallet, let's add the contract and deploy script. Rename the example generated contract file to CustomBaseToken.sol:

mv contracts/Lock.sol contracts/CustomBaseToken.sol

Open the CustomBaseToken.sol file and replace the example contract with the ERC20 contract below:

// SPDX-License-Identifier: Unlicensed
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";

contract CustomBaseToken is ERC20, Ownable, ERC20Burnable {
    constructor(string memory name, string memory symbol) ERC20(name, symbol) Ownable(msg.sender) {
        _mint(msg.sender, 100 * 10 ** decimals());
    }

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
}

Next, let's update the ignition module to deploy the ERC20 contract. Rename the module file with the command below:

mv ignition/modules/Lock.ts ignition/modules/CustomBaseToken.ts

Then, replace the module file with the code below:

import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";

const CustomBaseTokenModule = buildModule("CustomBaseTokenModule", (m) => {
  const tokenName = m.getParameter("name", "ZK Base Token");
  const tokenSymbol = m.getParameter("symbol", "ZKBT");

  const baseToken = m.contract("CustomBaseToken", [tokenName, tokenSymbol]);

  return { baseToken };
});

export default CustomBaseTokenModule;

To run the module and deploy the token contract, run:

npx hardhat ignition deploy ./ignition/modules/CustomBaseToken.ts --network localRethNode

Select y to confirm to deploy the contract to the localRethNode. After deploying, the token contract address should be logged in your console.

The constructor function in the contract should have minted tokens to the deployer address. Let's verify that the tokens were minted to the deployer address using the Foundry cast CLI:

cast balance --erc20 <0xYOUR_TOKEN_ADDRESS> 0x36615Cf349d7F6344891B1e7CA7C72883F5dc049 \
--rpc-url http://localhost:8545

Creating a new chain

Now that your ERC20 token is deployed, you can create a new chain.

First, shut down the node server running for zk_chain_1 by terminating the process.

Move back into your elastic chain ecosystem folder and run the chain create command:

zk_inception chain create

This time, use the answers below:

$ zk_inception chain create

   ZKsync toolbox
  What do you want to name the chain?
  custom_zk_chain
  What's the chain id?
│  272
◇  Select how do you want to create the wallet
│  Localhost
◇  Select the prover mode
│  NoProofs
◇  Select the commit data generator mode
│  Rollup
◇  Select the base token to use
│  Custom
◇  What is the token address?
│  <0x_YOUR_TOKEN_ADDRESS>
◇  What is the base token price nominator?
│  1
◇  What is the base token price denominator?
│  1
◇  Set this chain as default?
│  Yes

The base token nominator and denominator are used to define the relation of the base token price with ETH. For example, if we set the nominator to 20 and the denominator to 1, together the relation is 20/1, this would mean that 20 tokens would be given the equivalent value as 1 ETH for gas. For testing purposes, we'll just use a 1:1 ratio.

Funding the governor addresses

Now that you have some tokens and created a new chain, the next step is to send some to each of the governor's addresses for the ecosystem and chain on the L1. This will allow you to register and deploy your chain, and deploy the paymaster. For the ecosystem governor, you can find the address in <my_elastic_chain>/configs/wallets.yaml under governor.

The second governor address you need to fund is for the chain governor. The chain governor address can be found in <my_elastic_chain>/chains/custom_zk_chain/configs/wallets.yaml also under governor.

Run the command below twice (once for each governor address) to use cast to transfer some of your ERC20 tokens to the governor's address on the L1.

cast send <0xYOUR_TOKEN_ADDRESS> "transfer(address,uint256)" \
<0x_YOUR_GOVERNOR_ADDRESS> 1000000000000000000 \
--private-key 0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110 \
--rpc-url http://localhost:8545 \
--gas-price 30000000000

To verify that this worked, check the balance of both governor addresses:

cast balance --erc20 <0xYOUR_TOKEN_ADDRESS> \
<0x_YOUR_GOVERNOR_ADDRESS> \
--rpc-url http://localhost:8545

Initializing the chain

Make sure the server for zk_chain_1 that you started in the previous section is shut down.

Next, initialize the chain in the ecosystem with the command below, and select the default options for the prompts.

zk_inception chain init

During the initialization process, your ERC20 token address gets added to the allowlist mentioned earlier.

Now that the chain is initialized, you can start the chain server:

zk_inception server

Bridging the base token to your chain

The last step is to bridge some ERC20 tokens from the L1 to the new chain. Base tokens can not be minted on the L2 without being backed by the corresponding L1 amount. In the future, there will be more flexibility for this.

Open a new terminal and use ZKsync CLI to bridge the tokens with the command below:

npx zksync-cli bridge deposit --token <0x_YOUR_TOKEN_ADDRESS> \
--rpc=http://localhost:3050 \
--l1-rpc=http://localhost:8545
? Amount to deposit 5
? Private key of the sender 0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110
? Recipient address on L2 0x36615Cf349d7F6344891B1e7CA7C72883F5dc049

To verify that this worked, let's check the new balance of our address on the L2 chain:

npx zksync-cli wallet balance \
--address 0x36615Cf349d7F6344891B1e7CA7C72883F5dc049 \
--rpc http://localhost:3050

Note that we aren't checking for any specific token address here. The CLI will display the amount as ETH, but in reality, it's our bridged ERC20 tokens.

And that's it! You are now running a local ZK chain that uses a custom ERC20 base token. You can follow the steps in the previous section to deploy a contract to the new chain.

Bridging ETH to your chain

If your ERC20 token bridged to the L2 "acts" like ETH, then what happens if you bridge ETH to your custom chain?

The answer is that if you try to bridge regular ETH to this chain, it will just use a different token contract address.

You can try this out by depositing ETH from the L1 to this new chain:

npx zksync-cli bridge deposit \
--rpc=http://localhost:3050 \
--l1-rpc=http://localhost:8545
? Amount to deposit 20
? Private key of the sender 0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110
? Recipient address on L2 0x36615Cf349d7F6344891B1e7CA7C72883F5dc049

If you run the previous command to check the base token balance again, note how the balance is still 5. Because ETH is no longer the base token, it has a different token address on L2 you will need to reference. To find the L2 token address for ETH, you can use the l2TokenAddress method available through zksync-ethers.

To try this out, open your hardhat project zk-chain-test from the previous section, and run the commands below to create a new script file:

mkdir scripts
touch scripts/checkBalance.ts

Next, copy and paste the script below into the checkBalance.ts file:

import { ETH_ADDRESS_IN_CONTRACTS } from "zksync-ethers/build/utils.js";
import { getWallet } from "../deploy/utils";

async function main(){
    const wallet = getWallet();
    const l2ETHAddress = await wallet.l2TokenAddress(ETH_ADDRESS_IN_CONTRACTS);
    console.log("L2 ETH Address:", l2ETHAddress);

    const balance = await wallet.getBalance(l2ETHAddress);
    console.log("L2 ETH Balance 🎉:", balance);
}

main();

Run the script with the hardhat run command:

npx hardhat run scripts/checkBalance.ts

You should see the output below showing the same amount of ETH you bridged:

L2 ETH Address: <0x_YOUR_ETH_TOKEN_ADDRESS>
L2 ETH Balance 🎉: 20000000000000000000n

Switching between chains and shutting down the ecosystem

You can switch in between different chains without losing any state by terminating the chain server process and then running the command below:

zk_inception ecosystem change-default-chain

Now when you start the server with zk_inception server, the new default chain's node will start with the saved state.

To fully shut down the ecosystem and erase all of the data and state, you can shut down the Docker containers from the ecosystem folder using the command:

docker-compose down

To restart the docker containers for your ecosystem and run your custom ZK chain again, follow the steps below:

  1. In the ecosystem folder, run zk_inception containers.
  2. Redeploy your ERC20 contract to the L1.
  3. Update the base token address in <my_elastic_chain>/chains/custom_zk_chain/configs/contracts.yaml under l1.base_token_addr and in <my_elastic_chain>/chains/custom_zk_chain/ZkStack.yaml under base_token.address.
  4. Send ERC20 tokens to both of the ecosystem and chain governor addresses.
  5. Initialize the ecosystem with zk_inception ecosystem init --dev.
  6. Start the chain server with zk_inception server.
  7. Bridge ERC20 tokens from the L1 to L2.

Made with ❤️ by the ZKsync Community