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-toL2-L1 communication
Last Updated: Aug 18, 2024

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:

Set up

  1. Create a project folder and cd into it
    mkdir cross-chain-tx
    cd cross-chain-tx
    
  2. Initialise the project:
    npm init -y
    
  3. Install the required dependencies:
    npm install -D zksync-ethers ethers typescript dotenv @matterlabs/zksync-contracts @types/node
    
  4. Install ts-node globally to execute the scripts that we're going to create:
    npm install -g ts-node
    
  5. 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 l1provider = new Provider(<L1_RPC_ENDPOINT>);
    const l2provider = new Provider(<L2_RPC_ENDPOINT>);
    const wallet = new Wallet(WALLET_PRIVATE_KEY, l2provider, l1provider);
    
  2. Retrieve the current gas price on L1.
    // retrieve L1 gas price
    const l1GasPrice = await l1provider.getGasPrice();
    console.log(`L1 gasPrice ${ethers.utils.formatEther(l1GasPrice)} ETH`);
    
  3. Populate the transaction that you want to execute on L2:
    const contract = new Contract(<L2_CONTRACT_ADDRESS>, <ABI>, wallet);
    const message = `Message sent from L1 at ${new Date().toUTCString()}`;
    // populate tx object
    const tx = await contract.populateTransaction.setGreeting(message);
    
  4. Retrieve the gas limit for sending the populated transaction to L2 using estimateGasL1() from zksync-ethers Provider class. This function calls the zks_estimateGasL1ToL2 RPC method under the hood:
    // Estimate gas limit for L1-L2 tx
    const l2GasLimit = await l2provider.estimateGasL1(tx);
    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.utils.formatEther(baseCost)} ETH`);
    
  6. Encode the transaction calldata:
    const iface = new ethers.utils.Interface(<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,
      },
    });
    txReceipt.wait();
    

Full example

Create a send-l1-l2-tx.ts file in the root directory with the following script:

Execute the script by running:

ts-node send-l1-l2-tx.ts

You should see the following output:

Running script for L1-L2 transaction
L2 Balance is 1431116173253819600
L1 gasPrice 0.000000003489976197 ETH
Message in contract is Hello world!
L2 gasLimit 17207266
Executing this transaction will cost 0.005052478351484732 ETH
L1 tx hash is :>>  0x088700061730e84c316fa07296839263a420b65237fd0fd6a147b3d76affef76
🎉 Transaction sent successfully

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


Made with ❤️ by the ZKsync Community