L1 governance contract

Build and deploy a smart contract in L1 and send transactions that update the state of a contract in ZKsync.

In this tutorial we'll deploy a simple smart contract on ZKsync Era. Then we'll deploy another smart contract on Ethereum and send transactions that update the state of the contract on ZKsync Era using the ZKsync Bridge Hub.

What you'll learn:

  • How to deploy smart contracts in L1 and L2.
  • How to send transactions in L1 that interact with contracts in L2.
GitHub

Tools:

  • - zksync-cli
  • - ethers
  • - Hardhat
smart contractscross-chain
Last Updated: Mar 07, 2025

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 Era layer 2.
  • A Counter Solidity smart contract is deployed on ZKsync Era layer 2. This contract stores a number that is incremented
    by calling the increment method. The Governance contract on layer 1 calls this function.

Prerequisites

  • A wallet with sufficient Sepolia ETH on Ethereum and ZKsync Sepolia Testnet to pay for deploying smart contracts. You can get Sepolia ETH from the network faucets.
    • Get testnet ETH for ZKsync Era using bridges to bridge funds to ZKsync.
  • You know how to get your private key from your MetaMask wallet.
  • You must have Node.js installed.

Complete Project

Download the complete project on GitHub.

Project Setup

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. For now we will start with L1-governance folder.

mkdir L1-governance
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 the L1-governance folder.
cd L1-governance
  1. Initialise and set up the L1 project.
npx hardhat init

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:
  • Import it from the @matterlabs/zksync-contracts npm package (preferred).
  • Download it from the contracts repo.
  1. Install the following dependencies:
npm i -D typescript ts-node @openzeppelin/contracts @matterlabs/zksync-contracts@beta @nomicfoundation/hardhat-ethers @typechain/ethers-v6 @typechain/hardhat typechain ethers dotenv

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. Remove the existing /test directory and any contracts that exist in /contracts.
  2. Create a new file called Governance.sol inside the empty contracts folder.
touch contracts/Governance.sol
  1. Copy/paste the code below into it.
L1-governance/contracts/Governance.sol
// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.13;

import { IBridgehub, L2TransactionRequestDirect } from '@matterlabs/zksync-contracts/contracts/l1-contracts/bridgehub/IBridgehub.sol';

contract Governance {
  address public governor;

  constructor() {
    governor = msg.sender;
  }

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

    IBridgehub zksyncBridgeHub = IBridgehub(bridgeHubAddress);
    L2TransactionRequestDirect memory requestInput = L2TransactionRequestDirect(
      chainId,
      cost,
      contractAddr,
      0,
      data,
      gasLimit,
      gasPerPubdataByteLimit,
      new bytes[](0),
      msg.sender
    );
    zksyncBridgeHub.requestL2TransactionDirect{ value: msg.value }(requestInput);
  }
}

Deploy L1 Governance Contract

Create the file L1-Governance/.env and copy/paste the code below, filling in the relevant values. Find node provider urls here. You have to connect your wallet to the network and add the network to the wallet in advance.

L1-Governance/.env
NODE_RPC_URL=
PRIVATE_KEY=

Replace the code in hardhat.config.ts with the following:

L1-Governance/hardhat.config.ts
import type { HardhatUserConfig } from 'hardhat/config';
import '@nomicfoundation/hardhat-toolbox';

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

const config: HardhatUserConfig = {
  solidity: '0.8.24',
  networks: {
    // Sepolia network
    sepolia: {
      url: process.env.NODE_RPC_URL,
      accounts: [process.env.PRIVATE_KEY!],
    },
  },
};

export default config;

Next, create the file Governance.ts inside the /ignition/modules folder and copy/paste the following code into it:

touch ignition/modules/Governance.ts
L1-Governance/ignition/modules/Governance.ts
import { buildModule } from '@nomicfoundation/hardhat-ignition/modules';

const GovernanceModule = buildModule('GovernanceModule', (m) => {
  const governance = m.contract('Governance', [], {});

  return { governance };
});

export default GovernanceModule;

Then, from the L1-governance folder root, compile and deploy the contract:

npx hardhat compile
npx hardhat ignition deploy ./ignition/modules/Governance.ts --network sepolia

You should see the following output:

Deploying [ GovernanceModule ]
Batch #1
  Executed GovernanceModule#Governance

[ GovernanceModule ] successfully deployed 🚀

Deployed Addresses

GovernanceModule#Governance - 0xA7d27A1202bE1237919Cf2cb60970141100725b4

Save the address to use in a later step.

Prerequisites

  • You must have Docker installed.
  • A Node.js installation running at minimum Node.js version 18.

Complete Project

Download the complete project on GitHub.

Project Setup

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. For now we will start with L1-governance folder.

mkdir L1-governance
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 the L1-governance folder.
cd L1-governance
  1. Initialise and set up the L1 project.
npx hardhat init

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:
  • Import it from the @matterlabs/zksync-contracts npm package (preferred).
  • Download it from the contracts repo.
  1. Install the following dependencies:
npm i -D typescript ts-node @openzeppelin/contracts @matterlabs/zksync-contracts@beta @nomicfoundation/hardhat-ethers @typechain/ethers-v6 @typechain/hardhat typechain ethers dotenv

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. Remove the existing /test directory and any contracts that exist in /contracts.
  2. Create a new file called Governance.sol inside the empty contracts folder.
touch contracts/Governance.sol
  1. Copy/paste the code below into it.
L1-governance/contracts/Governance.sol
// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.13;

import { IBridgehub, L2TransactionRequestDirect } from '@matterlabs/zksync-contracts/contracts/l1-contracts/bridgehub/IBridgehub.sol';

contract Governance {
  address public governor;

  constructor() {
    governor = msg.sender;
  }

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

    IBridgehub zksyncBridgeHub = IBridgehub(bridgeHubAddress);
    L2TransactionRequestDirect memory requestInput = L2TransactionRequestDirect(
      chainId,
      cost,
      contractAddr,
      0,
      data,
      gasLimit,
      gasPerPubdataByteLimit,
      new bytes[](0),
      msg.sender
    );
    zksyncBridgeHub.requestL2TransactionDirect{ value: msg.value }(requestInput);
  }
}

Starting a Local Node

Open Docker Desktop so the Docker daemon is running in the background, and use zksync-cli to start a local node.

First, configure the CLI to use a dockerized node:

npx zksync-cli dev config

Use the arrow key to select Dockerized node in the prompt. To run a local block explorer, use the arrow keys and space bar to select the Block Explorer option in the prompt. Finally, press the Enter key to confirm the configuration.

Once configured, start the local dockerized node.

npx zksync-cli dev start

Deploy L1 Governance Contract

Create the file L1-Governance/.env and copy/paste the code below.

L1-Governance/.env
NODE_RPC_URL=http://127.0.0.1:8545
PRIVATE_KEY=0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110

Replace the code in hardhat.config.ts with the following:

L1-Governance/hardhat.config.ts
import type { HardhatUserConfig } from 'hardhat/config';
import '@nomicfoundation/hardhat-toolbox';

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

const config: HardhatUserConfig = {
  solidity: '0.8.24',
  networks: {
    // Sepolia network
    sepolia: {
      url: process.env.NODE_RPC_URL,
      accounts: [process.env.PRIVATE_KEY!],
    },
  },
};

export default config;

Next, create the file Governance.ts inside the /ignition/modules folder and copy/paste the following code into it:

touch ignition/modules/Governance.ts
L1-Governance/ignition/modules/Governance.ts
import { buildModule } from '@nomicfoundation/hardhat-ignition/modules';

const GovernanceModule = buildModule('GovernanceModule', (m) => {
  const governance = m.contract('Governance', [], {});

  return { governance };
});

export default GovernanceModule;

Then, from the L1-governance folder root, compile and deploy the contract:

npx hardhat compile
npx hardhat ignition deploy ./ignition/modules/Governance.ts --network sepolia

You should see the following output:

Deploying [ GovernanceModule ]
Batch #1
  Executed GovernanceModule#Governance

[ GovernanceModule ] successfully deployed 🚀

Deployed Addresses

GovernanceModule#Governance - 0xA7d27A1202bE1237919Cf2cb60970141100725b4

Save the address to use in a later step.


Made with ❤️ by the ZKsync Community