Send a transaction from L1 to L2

This how-to guide explains how to send a transaction from Ethereum that interacts with a contract deployed on ZKsync.

This how-to guide explains how to send a transaction from Ethereum that interacts with a contract deployed on ZKsync.

What you'll learn:

  • How L1-L2 communication works
  • How to send a transaction from ZKsync Ethereum to ZKsync.
GitHub

Tools:

  • - zksync-ethers
  • - zksync-contracts
smart contractshow-tocross-chain
Last Updated: Mar 27, 2025

The ZKsync Era smart contracts allow a sender to request transactions on Ethereum (L1) and pass data to a contract deployed on ZKsync Era (L2).

In this example, we'll send a transaction to an L2 contract from L1 using zksync-ethers, which provides helper methods to simplify the process.

Common use cases

Along with ZKsync Era's built-in censorship resistance that requires multi-layer interoperability, some common use cases need L1 to L2 transaction functionality, such as:

  • Custom bridges.
  • Multi-layer governing smart contracts.

Requirements

Before continuing, make sure you have the following:

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

Set up

Create a new project using zksync-cli:

npx zksync-cli create cross-chain-tx --template hardhat_solidity

In the root folder, add a .env file with the private key of the wallet to use:

WALLET_PRIVATE_KEY=0x..;
Always use a separate wallet with no real funds for development. Make sure your .env file is not pushed to an online repository by adding it to a .gitignore file.

Step-by-step

Sending an L1->L2 transaction requires following these steps:

  1. Initialise the providers and the wallet that will send the transaction:
      const [wallet] = await ethers.getWallets();
    
      // Initialize the L2 provider.
      const l2Provider = ethers.providerL2;
    
      // Initialize the L1 provider.
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const networkInfo = network as any;
      const l1RPCEndpoint =
        networkInfo.config.ethNetwork === 'http://localhost:8545'
          ? networkInfo.config.ethNetwork
          : '<YOUR_L1_RPC_ENDPOINT>';
      const l1Provider = new ethers.Provider(l1RPCEndpoint);
    
  2. Retrieve the current gas price on L1.
      // retrieve L1 gas price
      const l1GasPrice = await l1Provider.getGasPrice();
      console.log(`L1 gasPrice ${ethers.formatEther(l1GasPrice)} ETH`);
    
  3. Populate the transaction that you want to execute on L2:
      const contract = new ethers.Contract(L2_CONTRACT_ADDRESS, GREETER_ABI_JSON.abi, wallet);
      const message = `Message sent from L1 at ${new Date().toUTCString()}`;
      // populate tx object
      const tx = await contract.setGreeting.populateTransaction(message);
    
  4. Retrieve the gas limit for sending the populated transaction to L2 using estimateGasL1(). This function calls the zks_estimateGasL1ToL2 RPC method under the hood:
      // Estimate gas limit for L1-L2 tx
      const l2GasLimit = await l2Provider.estimateL1ToL2Execute({
        contractAddress: L2_CONTRACT_ADDRESS,
        calldata: tx.data,
        caller: wallet.address,
      });
      console.log(`L2 gasLimit ${l2GasLimit.toString()}`);
    
  5. Calculate the total transaction fee to cover the cost of sending the transaction on L1 and executing the transaction on L2 using getBaseCost from the Wallet class. This method:
    • Retrieves the ZKsync BridgeHub contract address by calling zks_getBridgehubContract RPC method.
    • Calls the l2TransactionBaseCost function on the ZKsync BridgeHub system contract to retrieve the fee.
      const baseCost = await wallet.getBaseCost({
        // L2 computation
        gasLimit: l2GasLimit,
        // L1 gas price
        gasPrice: l1GasPrice,
      });
    
      console.log(`Executing this transaction will cost ${ethers.formatEther(baseCost)} ETH`);
    
  6. Encode the transaction calldata:
      const iface = new ethers.Interface(GREETER_ABI_JSON.abi);
      const calldata = iface.encodeFunctionData('setGreeting', [message]);
    
  7. Finally, send the transaction from your wallet using the requestExecute method. This helper method:
    • Retrieves the Zksync BridgeHub contract address calling zks_getBridgehubContract.
    • Populates the L1-L2 transaction.
    • Sends the transaction to the requestL2TransactionDirect function of the ZKsync BridgeHub system contract on L1.
      const txReceipt = await wallet.requestExecute({
        // destination contract in L2
        contractAddress: L2_CONTRACT_ADDRESS,
        calldata,
        l2GasLimit: l2GasLimit,
        refundRecipient: wallet.address,
        overrides: {
          // send the required amount of ETH
          value: baseCost,
          gasPrice: l1GasPrice,
        },
      });
    
      console.log(`L1 tx hash is ${txReceipt.hash}`);
      console.log('🎉 Transaction sent successfully');
      txReceipt.wait(1);
    

Full example

Create a new script file in the scripts folder called l1tol2tx.ts, and copy/paste the full script.

touch scripts/l1tol2tx.ts

You'll need to update <YOUR_L1_RPC_ENDPOINT> with your RPC endpoint.

The deployed contract address on Zksync Sepolia testnet is already in the script file. To import the ABI file from the template Greeter contract, run the command below to compile the contract.

npm run compile

Next, execute the script by running:

npx hardhat run scripts/l1tol2tx.ts

You should see the following output:

L1 gasPrice 0.00000000321159928 ETH
L2 gasLimit 0xa4910
Executing this transaction will cost 0.000195576293213424 ETH
L1 tx hash is 0x69a382f7157772f24984c4985e81258e7d5dfc2f0ba762a6cff41304e8987d9c
🎉 Transaction sent successfully

This indicates the transaction is submitted to the L1 and will later on be executed on L2.

To verify this, wait until the transaction is finalized, and try running the interact.ts script. You can remove the second part of the script so that it just checks the value of the greet function.

npm run interact

You should see in the logs your message sent from the L1.

Running script to interact with contract 0x543A5fBE705d040EFD63D9095054558FB4498F88
Current message is: Message sent from L1 at <CURRENT_DATE>

Requirements

Before continuing, make sure you have the following:

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

Set up

Create a new project using zksync-cli:

npx zksync-cli create cross-chain-tx --template hardhat_solidity

For the private key prompt, you can use one of the pre-configured rich wallets like the example below:

? Private key of the wallet responsible for deploying contracts (optional)
0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110

Next, you need to configure the ZKsync CLI to use a Dockerized node.

npx zksync-cli dev config

Then, open Docker desktop so Docker is running in background, and start the nodes:

npx zksync-cli dev start

Once the node is running, change the default network in your hardhat.config.ts file to dockerizedNode.

hardhat.config.ts
  defaultNetwork: 'dockerizedNode',

Let's use the default Greeter contract in the template to use for testing. Deploy the Greeter contract using the commands below:

npm run compile
npm run deploy

Save the contract address to use later.

Step-by-step

Sending an L1->L2 transaction requires following these steps:

  1. Initialise the providers and the wallet that will send the transaction:
      const [wallet] = await ethers.getWallets();
    
      // Initialize the L2 provider.
      const l2Provider = ethers.providerL2;
    
      // Initialize the L1 provider.
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const networkInfo = network as any;
      const l1RPCEndpoint =
        networkInfo.config.ethNetwork === 'http://localhost:8545'
          ? networkInfo.config.ethNetwork
          : '<YOUR_L1_RPC_ENDPOINT>';
      const l1Provider = new ethers.Provider(l1RPCEndpoint);
    
  2. Retrieve the current gas price on L1.
      // retrieve L1 gas price
      const l1GasPrice = await l1Provider.getGasPrice();
      console.log(`L1 gasPrice ${ethers.formatEther(l1GasPrice)} ETH`);
    
  3. Populate the transaction that you want to execute on L2:
      const contract = new ethers.Contract(L2_CONTRACT_ADDRESS, GREETER_ABI_JSON.abi, wallet);
      const message = `Message sent from L1 at ${new Date().toUTCString()}`;
      // populate tx object
      const tx = await contract.setGreeting.populateTransaction(message);
    
  4. Retrieve the gas limit for sending the populated transaction to L2 using estimateGasL1(). This function calls the zks_estimateGasL1ToL2 RPC method under the hood:
      // Estimate gas limit for L1-L2 tx
      const l2GasLimit = await l2Provider.estimateL1ToL2Execute({
        contractAddress: L2_CONTRACT_ADDRESS,
        calldata: tx.data,
        caller: wallet.address,
      });
      console.log(`L2 gasLimit ${l2GasLimit.toString()}`);
    
  5. Calculate the total transaction fee to cover the cost of sending the transaction on L1 and executing the transaction on L2 using getBaseCost from the Wallet class. This method:
    • Retrieves the ZKsync BridgeHub contract address by calling zks_getBridgehubContract RPC method.
    • Calls the l2TransactionBaseCost function on the ZKsync BridgeHub system contract to retrieve the fee.
      const baseCost = await wallet.getBaseCost({
        // L2 computation
        gasLimit: l2GasLimit,
        // L1 gas price
        gasPrice: l1GasPrice,
      });
    
      console.log(`Executing this transaction will cost ${ethers.formatEther(baseCost)} ETH`);
    
  6. Encode the transaction calldata:
      const iface = new ethers.Interface(GREETER_ABI_JSON.abi);
      const calldata = iface.encodeFunctionData('setGreeting', [message]);
    
  7. Finally, send the transaction from your wallet using the requestExecute method. This helper method:
    • Retrieves the Zksync BridgeHub contract address calling zks_getBridgehubContract.
    • Populates the L1-L2 transaction.
    • Sends the transaction to the requestL2TransactionDirect function of the ZKsync BridgeHub system contract on L1.
      const txReceipt = await wallet.requestExecute({
        // destination contract in L2
        contractAddress: L2_CONTRACT_ADDRESS,
        calldata,
        l2GasLimit: l2GasLimit,
        refundRecipient: wallet.address,
        overrides: {
          // send the required amount of ETH
          value: baseCost,
          gasPrice: l1GasPrice,
        },
      });
    
      console.log(`L1 tx hash is ${txReceipt.hash}`);
      console.log('🎉 Transaction sent successfully');
      txReceipt.wait(1);
    

Full example

Create a new script file in the scripts folder called l1tol2tx.ts, and copy/paste the full script.

touch scripts/l1tol2tx.ts

Update the L2_CONTRACT_ADDRESS variable with your deployed Greeter contract address.

Next, execute the script by running:

npx hardhat run scripts/l1tol2tx.ts

You should see the following output:

L1 gasPrice 0.00000000321159928 ETH
L2 gasLimit 0xa4910
Executing this transaction will cost 0.000195576293213424 ETH
L1 tx hash is 0x69a382f7157772f24984c4985e81258e7d5dfc2f0ba762a6cff41304e8987d9c
🎉 Transaction sent successfully

This indicates the transaction is submitted to the L1 and will later on be executed on L2.

To verify this, wait until the transaction is finalized, and try running the interact.ts script. You can remove the second part of the script so that it just checks the value of the greet function.

npm run interact

You should see in the logs your message sent from the L1.

Running script to interact with contract 0x543A5fBE705d040EFD63D9095054558FB4498F88
Current message is: Message sent from L1 at <CURRENT_DATE>

Made with ❤️ by the ZKsync Community