L1-L2 transactions

Deploy a contract in ZKsync that has a counter.

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"
  2. Paste it into a new file: /L2-counter/scripts/governance.json.
  3. 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 sepolia.json file.
    import { Contract, Wallet, Interface } from "ethers";
    import { Provider, utils } from "zksync-ethers";
    // load env file
    import dotenv from "dotenv";
    const GOVERNANCE_ABI = require("./governance.json");
    const COUNTER_ABI = require("./counter.json");
    const PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY || "";
    if (!PRIVATE_KEY) throw "⛔️ Private key not detected! Add it to the .env file!";
    // Initialize the wallet.
    async function main() {
      // Enter your Ethereum L1 provider RPC URL.
      const l1Provider = new Provider("<L1-RPC-URL>");
      // Set up the Governor wallet to be the same as the one that deployed the governance contract.
      const wallet = new Wallet(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("https://sepolia.era.zksync.dev");
      // 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 L1 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 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](../developer-guides/transactions/fee-model.md).
      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.
  4. Run the script with the following command:
    npx 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 Sepolia Testnet Block Explorer. 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],
  5. 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-ethers SDK, check out its documentation.
  • To learn more about the ZKsync hardhat plugins, check out their documentation.

Made with ❤️ by the ZKsync Community