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:
- 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 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..;
.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 [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);
- Retrieve the current gas price on L1.
// retrieve L1 gas price const l1GasPrice = await l1Provider.getGasPrice(); console.log(`L1 gasPrice ${ethers.formatEther(l1GasPrice)} ETH`);
- 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);
- Retrieve the gas limit for sending the populated transaction to L2 using
estimateGasL1()
. This function calls thezks_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()}`);
- 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.formatEther(baseCost)} ETH`);
- Retrieves the ZKsync BridgeHub contract address by calling
- Encode the transaction calldata:
const iface = new ethers.Interface(GREETER_ABI_JSON.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, }, }); console.log(`L1 tx hash is ${txReceipt.hash}`); console.log('🎉 Transaction sent successfully'); txReceipt.wait(1);
- Retrieves the Zksync BridgeHub contract address calling
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>