Cross-chain governance

Cross-chain governance

This tutorial shows you how to implement communication between L1 and L2 with the following example:

  • A Governance Solidity smart contract is deployed on layer 1. This contract has a function that sends a transaction to zkSync layer 2.
  • A Counter Solidity smart contract is deployed on zkSync layer 2. This contract stores a number that is incremented by calling the increment method. The Governance contract on layer 1 calls this function.


Local zkSync Testing with zksync-cli

Skip the hassle for test ETH by using zksync-cli for local testing. Simply execute npx zksync-cli dev start to initialize a local zkSync development environment, which includes local Ethereum and zkSync nodes. This method allows you to test contracts without requesting external testnet funds. Explore more in the zksync-cli documentation.

Complete project

Download the complete project hereopen in new window.

Project set up

Open a terminal window, create a new folder for the project tutorial, e.g. mkdir cross-chain-tutorial, and cd into the folder.

Now create separate folders to store contracts and scripts on L1 and L2.

mkdir L1-governance L2-counter


  • The L1-governance code is a default Hardhat project used to deploy a contract on L1.
  • The L2-counter code includes all zkSync dependencies and configurations for L2.

L1 governance

  1. cd into L1-governance.

  2. Run the following to initialise and set up the L1 project:

npx hardhat


In case of question: Need to install the following packages: hardhat@version Ok to proceed? (y)

  • Press (y)

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


To interact with the zkSync bridge contract using Solidity, you need the zkSync contract interface. There are two ways to get it:

  1. Install the following dependencies:

Make sure you use actual node (lts version) and actual npm version

yarn add -D typescript ts-node @openzeppelin/contracts @matterlabs/zksync-contracts @nomiclabs/hardhat-ethers @nomiclabs/hardhat-waffle ethereum-waffle

Create L1 governance contract


Make sure you're still in the L1-governance folder.

The following Solidity code defines the Governance smart contract.

The constructor sets the contract creator as the single governor. The callZkSync function calls a transaction on L2 which can only be called by the governor.

  1. cd into the contracts\ folder and remove any files already there, if any.

  2. Create a file called Governance.sol and copy/paste the code below into it.

// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.13;

import "@matterlabs/zksync-contracts/l1/contracts/zksync/interfaces/IZkSync.sol";

contract Governance {
    address public governor;

    constructor() {
        governor = msg.sender;

    function callZkSync(
        address zkSyncAddress,
        address contractAddr,
        bytes memory data,
        uint256 gasLimit,
        uint256 gasPerPubdataByteLimit
    ) external payable {
        require(msg.sender == governor, "Only governor is allowed");

        IZkSync zksync = IZkSync(zkSyncAddress);
        zksync.requestL2Transaction{value: msg.value}(contractAddr, 0,
            data, gasLimit, gasPerPubdataByteLimit, new bytes[](0), msg.sender);

Deploy L1 governance contract

  1. Create the file L1-Governance/goerli.json and copy/paste the code below, filling in the relevant values. Find node provider urls hereopen in new window. You have to connect your wallet to the network and add the network to the wallet in advance.
  "nodeUrl": "<GOERLI NODE URL>",
  "deployerPrivateKey": "<YOUR PRIVATE KEY>"
  1. Replace the code in hardhat.config.ts with the following:
import { HardhatUserConfig } from "hardhat/config";
import "@nomiclabs/hardhat-waffle";

// import file with Göerli params
const goerli = require("./goerli.json");

const config: HardhatUserConfig = {
  solidity: {
    version: "0.8.19",
  networks: {
    // Göerli network
    goerli: {
      url: goerli.nodeUrl,
      accounts: [goerli.deployerPrivateKey],

export default config;
  1. Navigate to the scripts folder and copy/paste the following code into the deploy.ts file (removing any previous code):
// We require the Hardhat Runtime Environment explicitly here. This is optional
// but useful for running the script in a standalone fashion through `node <script>`.
// When running the script with `npx hardhat run <script>` you'll find the Hardhat
// Runtime Environment's members available in the global scope.
import { ethers } from "hardhat";

async function main() {
  // We get the contract to deploy
  const Governance = await ethers.getContractFactory("Governance");

  const contract = await Governance.deploy();
  await contract.deployed();

  console.log(`Governance contract was successfully deployed at ${contract.address}`);

// We recommend always using this async/await pattern to properly handle errors.
main().catch((error) => {
  process.exitCode = 1;
  1. From the L1-governance folder root, compile and deploy the contract:
# compile contract
yarn hardhat compile

# deploy contract
yarn hardhat run --network goerli ./scripts/deploy.ts

You should see output like this:

Governance contract was successfully deployed at 0xf28Df77fa8ff56cA3084bd11c1CAF5033A7b8C4A

Save the address to use in a later step.

L2 counter

Now that we have an address for the L1 governance contract, we can build, deploy, and test the counter contract on L2.

  1. cd into /L2-counter and initialize the project:
npx zksync-cli create-project . --template hardhat_solidity


In case of any issues on this step (f.e. you did not have a yarn), you may get errors during the recreation of the project environment. Clean your current environment in advance or create another folder and create environment there.

  1. For the purposes of this tutorial, we don't need the Greeter related files generated by the zkSync-CLI. So, proceed with removing Greeter.sol from our /contracts directory:
rm -rf ./contracts/Greeter.sol
  1. Similarly, remove the deploy scripts associated with the Greeter contract:
rm -rf ./deploy/deploy-greeter.ts && rm -rf ./deploy/use-greeter.ts

Create L2 counter contract

  1. In the L2-counter/contracts/ directory, create a new file Counter.sol.

This contract contains the address of the governance contract deployed previously on layer 1, and an incrementable counter which can only be invoked by the governance contract.

  1. Copy/paste the following code into the file:
// SPDX-License-Identifier: Unlicense

pragma solidity ^0.8.13;

contract Counter {
    uint256 public value = 0;
    address public governance;

    constructor(address newGovernance) {
        governance = newGovernance;

    function increment() public {
        require(msg.sender == governance, "Only governance is allowed");

        value += 1;
  1. Compile the contract from the L2-counter root:


    Compilations happens only after the installation of a compiler

yarn hardhat compile

Deploy L2 counter contract

  1. Copy/paste the following code into L2-counter/deploy/deploy.ts, replacing <GOVERNANCE-ADDRESS> with the address of the Governance contract we just deployed, and <WALLET-PRIVATE-KEY> with your private key:
import { utils, Wallet } from "zksync-web3";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import { Deployer } from "@matterlabs/hardhat-zksync-deploy";

// Insert the address of the governance contract

// An example of a deploy script that will deploy and call a simple contract.
export default async function (hre: HardhatRuntimeEnvironment) {
  console.log(`Running deploy script for the Counter contract`);

  // Initialize the wallet.
  const wallet = new Wallet("<WALLET-PRIVATE-KEY>");

  // Create deployer object and load the artifact of the contract you want to deploy.
  const deployer = new Deployer(hre, wallet);
  const artifact = await deployer.loadArtifact("Counter");

  // Deposit some funds to L2 to be able to perform deposits.
  const deploymentFee = await deployer.estimateDeployFee(artifact, [utils.applyL1ToL2Alias(GOVERNANCE_ADDRESS)]);
  const depositHandle = await deployer.zkWallet.deposit({
    to: deployer.zkWallet.address,
    token: utils.ETH_ADDRESS,
    amount: deploymentFee.mul(2),
  // Wait until the deposit is processed on zkSync
  await depositHandle.wait();

  // Deploy this contract. The returned object will be of a `Contract` type, similar to the ones in `ethers`.
  // The address of the governance is an argument for contract constructor.
  const counterContract = await deployer.deploy(artifact, [utils.applyL1ToL2Alias(GOVERNANCE_ADDRESS)]);

  // Show the contract info.
  const contractAddress = counterContract.address;
  console.log(`${artifact.contractName} was deployed to ${contractAddress}`);

Deposit funds during deployment

The deployment script contains a deposit from Goerli to zkSync Era testnet, which can take a few minutes to finish. If your wallet already has funds in L2, you can skip that part to save you some time.

  1. Now deploy the contract from the L2-counter/ folder root to zkSync:
yarn hardhat deploy-zksync

You should see output like this:

Running deploy script for the Counter contract
Counter was deployed to 0x3c5A6AB2390F6217C78d2F6F403A9dFb7e7784FC


For more information about deploying contracts, check out the quickstart tutorial or the documentation for the zkSync hardhat plugins.

Read the counter value

Now both contracts are deployed, we can create a script to retrieve the value of the counter.

  1. Create the scripts directory under L2-counter.

  2. Copy the abi array from the compilation artifact located at /L2-counter/artifacts-zk/contracts/Counter.sol/Counter.json.

  3. Create a new file /L2-counter/scripts/counter.json and paste in the abi array.

  4. Create a /L2-counter/scripts/display-value.ts file and paste in the following code, adding the counter contract address:

import { Contract, Provider } from "zksync-web3";

const COUNTER_ABI = require("./counter.json");

async function main() {
  // Initialize the provider
  const l2Provider = new Provider("");

  const counterContract = new Contract(COUNTER_ADDRESS, COUNTER_ABI, l2Provider);

  const value = (await counterContract.value()).toString();

  console.log(`The counter value is ${value}`);

main().catch((error) => {
  process.exitCode = 1;
  1. Run the script:
yarn ts-node ./scripts/display-value.ts

The output should be:

The counter value is 0

Call L2 contract from L1

Now, let's call the increment method on Layer 2 from Layer 1.

  1. Copy the abi array from the compilation artifact located at:


You have to copy only abi content from the file after the keyword abi, an example below:

    "inputs": [
        "internalType": "address",
        "name": "newGovernance",
        "type": "address"
    "stateMutability": "nonpayable",
    "type": "constructor"
    "inputs": [],
    "name": "governance",
    "outputs": [
        "internalType": "address",
        "name": "",
        "type": "address"
    "stateMutability": "view",
    "type": "function"
    "inputs": [],
    "name": "increment",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
    "inputs": [],
    "name": "value",
    "outputs": [
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
    "stateMutability": "view",
    "type": "function"
  1. Paste it into a new file: /L2-counter/scripts/governance.json.

  2. Create the L2-counter/scripts/increment-counter.ts file and paste in the following code, replacing the following details:

  • GOVERNANCE-ADDRESS: the address of the contract deployed in L1.
  • COUNTER-ADDRESS: the address of the contract deployed in L2.
  • WALLET-PRIVATE-KEY: the private key of your account.
  • RPC-URL: the same url you used in the goerli.json file.
import { BigNumber, Contract, ethers, Wallet } from "ethers";
import { Provider, utils } from "zksync-web3";
const GOVERNANCE_ABI = require("./governance.json");
const COUNTER_ABI = require("./counter.json");

async function main() {
  // Enter your Ethereum L1 provider RPC URL.
  const l1Provider = new ethers.providers.JsonRpcProvider("<RPC-URL>");
  // Set up the Governor wallet to be the same as the one that deployed the governance contract.
  const wallet = new ethers.Wallet("<YOUR-PRIVATE-KEY>", l1Provider);
  // Set a constant that accesses the Layer 1 contract.
  const govcontract = new Contract(GOVERNANCE_ADDRESS, GOVERNANCE_ABI, wallet);

  // Initialize the L2 provider.
  const l2Provider = new Provider("");
  // Get the current address of the zkSync L1 bridge.
  const zkSyncAddress = await l2Provider.getMainContractAddress();
  // Get the `Contract` object of the zkSync bridge.
  const zkSyncContract = new Contract(zkSyncAddress, utils.ZKSYNC_MAIN_ABI, wallet);

  // Encoding the L2 transaction is done in the same way as it is done on Ethereum.
  // Use an Interface which gives access to the contract functions.
  const counterInterface = new ethers.utils.Interface(COUNTER_ABI);
  const data = counterInterface.encodeFunctionData("increment", []);

  // The price of an L1 transaction depends on the gas price used.
  // You should explicitly fetch the gas price before making the call.
  const gasPrice = await l1Provider.getGasPrice();

  // Define a constant for gas limit which estimates the limit for the L1 to L2 transaction.
  const gasLimit = await l2Provider.estimateL1ToL2Execute({
    contractAddress: COUNTER_ADDRESS,
    calldata: data,
    caller: utils.applyL1ToL2Alias(GOVERNANCE_ADDRESS),
  // baseCost takes the price and limit and formats the total in wei.
  // For more information on `REQUIRED_L1_TO_L2_GAS_PER_PUBDATA_LIMIT` see the [fee model documentation](../../reference/concepts/
  const baseCost = await zkSyncContract.l2TransactionBaseCost(gasPrice, gasLimit, utils.REQUIRED_L1_TO_L2_GAS_PER_PUBDATA_LIMIT);

  // !! If you don't include the gasPrice and baseCost in the transaction, a re-estimation of fee may generate errors.
  const tx = await govcontract.callZkSync(zkSyncAddress, COUNTER_ADDRESS, data, gasLimit, utils.REQUIRED_L1_TO_L2_GAS_PER_PUBDATA_LIMIT, {
    // Pass the necessary ETH `value` to cover the fee for the operation
    value: baseCost,

  // Wait until the L1 tx is complete.
  await tx.wait();

  // Get the TransactionResponse object for the L2 transaction corresponding to the execution call.
  const l2Response = await l2Provider.getL2TransactionFromPriorityOp(tx);

  // Output the receipt of the L2 transaction corresponding to the call to the counter contract.
  const l2Receipt = await l2Response.wait();

// We recommend always using this async/await pattern to properly handle errors.
main().catch((error) => {
  process.exitCode = 1;


  • Executing transactions from L1 requires the caller to pay a fee to the L2 operator. The fee depends on the length of the calldata and the gasLimit. This is similar to the gasLimit on Ethereum. You can read more about the zkSync fee model here.
  • The fee also depends on the gas price that is used during the transaction call. So to have a predictable fee for the call, the gas price should be fetched from the L1 provider.
  1. Run the script with the following command:
yarn ts-node ./scripts/increment-counter.ts

In the output, you should see the full transaction receipt in L2. You can take the transactionHash and track it in the zkSync exploreropen in new window. It should look something like this:

  to: '0x9b379893bfAD08c12C2167C3e3dBf591BeD9410a',
  from: '0xE2EA97507a6cb610c81c4A9c157B8060E2ED7036',
  contractAddress: null,
  transactionIndex: 0,
  root: '0xb9ca78c288163a322a797ee671db8e9ab430eb00e38c4a989f2246ea22493945',
  gasUsed: BigNumber { _hex: '0x05c3df', _isBigNumber: true },
  logsBloom: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
  blockHash: '0xb9ca78c288163a322a797ee671db8e9ab430eb00e38c4a989f2246ea22493945',
  transactionHash: '0x1fb19cc0aca8fcccaf5fbafd9174550f3151d0d2aa15d99eb820e0394313e409',
  logs: [
      transactionIndex: 0,
      blockNumber: 4119331,
      transactionHash: '0x1fb19cc0aca8fcccaf5fbafd9174550f3151d0d2aa15d99eb820e0394313e409',
      address: '0x000000000000000000000000000000000000800A',
      topics: [Array],

  1. Verify that the transaction was successful by running the display-value script again.
npx ts-node ./scripts/display-value.ts

You should see an incremented value in the output:

The counter value is 1

Learn more

  • To learn more about L1->L2 interaction on zkSync, check out the documentation.
  • To learn more about the zksync-web3 SDK, check out its documentation.
  • To learn more about the zkSync hardhat plugins, check out their documentation.
Last update:
Contributors: Antonio,niramisa,Blessing Krofegha,Dustin Brickwood,Jack Hamer,Nicolás Bevilacqua,Antonio,Dennis,Lyova Potyomkin,Paul Dowman,Samlior,Stanislav Bezkorovainyi,barakshani,defigen,merdan,omahs,pcheremu