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.
What you'll learn:
- How L1-L2 communication works
- How to send a transaction from ZKsync Ethereum to ZKsync.
Tools:
- - zksync-ethers
- - zksync-contracts
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:
- Destination contract ABI: to execute a transaction on a contract on L2,
you'll need the contract's ABI. For this example, we'll use a
Greeter.sol
contract that is deployed on ZKsync Sepolia testnet). - L1 RPC endpoint: to broadcast the L1->L2 transaction to the network. You can find public Sepolia RPC endpoints in Chainlist.
- L1 Account with ETH to pay the gas fees. Use any of the listed Sepolia faucets.
Set up
- Create a project folder and
cd
into itmkdir cross-chain-tx cd cross-chain-tx
- Initialise the project:
npm init -y
- Install the required dependencies:
npm install -D zksync-ethers ethers typescript dotenv @matterlabs/zksync-contracts @types/node
- Install
ts-node
globally to execute the scripts that we're going to create:npm install -g ts-node
- 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:
- 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);
- 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`);
- 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);
- Retrieve the gas limit for sending the populated transaction to L2 using
estimateGasL1()
fromzksync-ethers
Provider
class. This function calls thezks_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()}`);
- Calculate the total transaction fee to cover the cost of sending the
transaction on L1 and executing the transaction on L2 using
getBaseCost
from theWallet
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`);
- Retrieves the ZKsync BridgeHub contract address by calling
- Encode the transaction calldata:
const iface = new ethers.utils.Interface(<ABI>); const calldata = iface.encodeFunctionData("setGreeting", [message]);
- 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();
- Retrieves the Zksync BridgeHub contract address calling
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.