L1 and EVM
The L1 bridge ZkLighter intermediates messaging between L1 and Lighter EVM.
Arbitrary cross-domain messaging is supported between the two domains.
L1 to EVM
L1 accounts can submit transactions to Lighter EVM through the bridge's EVM priority queue. The EVM sequencer processes these requests asynchronously in first-in, first-out order.
For example, a caller can request setValue(42) on an EVM contract by setting to to the target address and encoding the call in data.
Submitting an EVM priority request
Call ZkLighter.addEvmPriorityRequest(...) on L1 to submit a request.
function addEvmPriorityRequest(
address to,
uint256 value,
uint256 depositAmount,
uint256 gasLimit,
bool isCreation,
address refundAddress,
bytes memory data
) external payable;The parameters define the transaction to execute on Lighter EVM.
address tois the target of the EVM transaction.uint256 valueis the amount of ETH to send to the target with the transaction on EVM. It comes out of the sender's EVM balance afterdepositAmountis credited, so a sender with no EVM balance must setdepositAmountto at leastvalue.uint256 depositAmountis the amount of ETH to deposit on EVM for the (potentially aliased)msg.sender. Once the priority request is accepted on L1, this amount is guaranteed to be credited to that EVM address, even if the EVM transaction fails.uint256 gasLimitis the gas limit for the EVM transaction.bool isCreationselects whether the transaction is a contract-creation transaction. Iftrue,tomust beaddress(0)anddatamust contain the creation bytecode, including any encoded constructor arguments.address refundAddressis the address on L1 that will receive any excessmsg.valueafter fees and the deposit amount are deducted.bytes memory datais the transaction's calldata, or its initialization code whenisCreationistrue.
msg.value must cover at least the priority request fee plus depositAmount. Any excess is refunded to refundAddress. The fee is forwarded to the protocol treasury.
A successful L1 transaction confirms submission, not successful execution on EVM.
Priority request fee calculation
Estimate the fee by calling computePriorityFee(gasLimit) on the ZkLighter bridge with the gas limit you plan to request.
function computePriorityFee(uint256 gasLimit) public view returns (uint256);The fee depends on the base fee (block.basefee) and blob base fee (block.blobbasefee) of the L1 block that includes the request. These can change before inclusion, so add a buffer to the estimate and send it along with depositAmount. Any msg.value above the actual fee plus depositAmount is refunded to refundAddress.
The fee itself is charged in full and is not refunded for unused execution gas or pubdata.
When quoting with eth_call, explicitly set a nonzero gasPrice at least as high as the selected L1 block's base fee. Otherwise, the node can execute the call with block.basefee set to zero and underestimate the fee. No block override is needed to use the selected block's base fee and blob base fee.
With cast, pin the quote to the latest block and read its base fee.
quote_block=$(cast block-number --rpc-url "$L1_RPC_URL")
quote_base_fee=$(cast basefee "$quote_block" --rpc-url "$L1_RPC_URL")
QUOTE_CALLER=0x000000000000000000000000000000000000dEaD
REQUEST_GAS_LIMIT=200000
cast call "$ZK_LIGHTER_BRIDGE" \
'computePriorityFee(uint256)(uint256)' "$REQUEST_GAS_LIMIT" \
--rpc-url "$L1_RPC_URL" \
--block "$quote_block" \
--from "$QUOTE_CALLER" \
--legacy \
--gas-price "$((quote_base_fee + 1))" \
--gas-limit 100000 \
--override-balance "$QUOTE_CALLER:1000000000000000000"As in the example, you may also need to override the caller's balance to satisfy the simulation's gas-payment check. This does not fund the account on-chain or spend ETH. Here, --gas-limit limits the fee-quote call itself; REQUEST_GAS_LIMIT is the gas requested for the EVM priority transaction.
msg.sender of the EVM priority transaction
msg.sender of the EVM priority transactionTo prevent contract-address spoofing, contract-senders of EVM priority transactions are aliased on Lighter EVM using the following rule.
uint160 constant OFFSET = uint160(0x1111000000000000000000000000000000001111);
function _applyL1ToL2Alias(address sender) internal pure returns (address l2Address) {
/// @solidity memory-safe-assembly
unchecked {
l2Address = address(uint160(sender) + OFFSET);
}
}Whether aliasing applies is determined at runtime by the L1 check msg.sender != tx.origin. EIP-7702 accounts can choose to trigger or avoid aliasing depending on how they submit the request.
The EVM target can authenticate the expected L1 caller as follows.
- EOA sender without aliasing. Check
msg.sender == expectedL1Sender. - Contract-sender with aliasing. Undo the alias before comparing the resulting address with the expected L1 sender.
function _undoL1ToL2Alias(address sender) internal pure returns (address l1Address) {
unchecked {
l1Address = address(uint160(sender) - OFFSET);
}
}Finding the corresponding L2 transaction
Each EVM priority request produces exactly one L2 transaction. Its hash is deterministic, so you can compute it from the corresponding event in the L1 transaction receipt.
-
Decode the
NewEvmPriorityRequestevent emitted by the L1 bridgeZkLighter. It has the following fields.event NewEvmPriorityRequest( uint64 indexed requestId, address indexed sender, address indexed to, uint256 mintAmount, uint256 value, uint256 gasLimit, bool isCreation, bytes data, uint64 expirationTimestamp, bytes32 prefixHash ); -
Compute
sourceHash = keccak256(prefixHash), hashing the 32 raw bytes of the event'sprefixHash. -
RLP-encode the fields in the order shown below, prepend the single transaction-type byte
0x7E, then hash the resulting bytes.
l2TxHash = keccak256(0x7E || rlp([
uint8(1), sourceHash, sender, to, mintAmount, value, gasLimit, isCreation, data
]))Failure model
An EVM priority request is consumed when its transaction is processed, even if the transaction reverts. Reversion undoes the EVM call's effects, including any transfer of value to the target, but does not undo the initiating L1 transaction or the depositAmount credited to the sender's EVM address. The request is not retryable.
Some protocols need the EVM action to succeed after an operation has already completed on L1. For example, tokens locked on L1 may still need to be minted on EVM after a failed first attempt. For stronger delivery guarantees, use L1ReplayableMessenger to make the message retryable while preventing duplicate successful execution.
L1ReplayableMessenger
L1ReplayableMessengerL1ReplayableMessenger builds on the EVM priority queue and relays messages through EvmReplayableMessenger on EVM. Together, they handle replayability and abstract away sender aliasing for applications.
To initiate a replayable message, call sendMessage(...) on L1 with the target and encoded call. Make sure to supply the appropriate priority request fee along with value as msg.value. Any excess is returned to refundAddress.
function sendMessage(
address target,
bytes calldata message,
uint256 gasLimit,
uint256 value,
address refundAddress
) external payable;If delivery fails, anyone can call replayMessage(...) on L1 with the original message fields and a new gas limit. Each retry submits a new priority request and pays a fresh priority fee.
function replayMessage(
address _from,
address _to,
uint256 _value,
uint256 _messageNonce,
address _refundAddress,
bytes memory _message,
uint32 _newGasLimit
) external payable;_fromis the original caller ofL1ReplayableMessenger.sendMessage(...), without aliasing. For a gateway transfer, this is the gateway contract._tois the originaltargetaddress on EVM._valueis the originalvalue, in wei. The retry'smsg.valueonly needs to cover the new priority request fee._messageNonceis the original submission'sNewEvmPriorityRequest.requestId. Reuse it on every retry, even though each retry creates a new priority request._refundAddressis the L1 address that receives any excess fee payment for this retry. It may differ from the original refund address._messageis the originalmessagecalldata passed tosendMessage(...)._newGasLimitis the gas limit for the new EVM relay transaction. It can be increased for the retry.
Recover _from, _to, _messageNonce, and _message by decoding relayMessage(...) from the original NewEvmPriorityRequest event's data field. Use that event's value field for _value.
A failed relay leaves the message available for another attempt. Once the target call succeeds, the same message cannot execute again. Replayability does not guarantee eventual success if the target continues to revert.
At the target, msg.sender is EvmReplayableMessenger, not the original L1 sender or its alias. For authentication purposes, the target should check both conditions.
msg.sender == address(EVM_REPLAYABLE_MESSENGER), using theEvmReplayableMessengerpredeploy.EVM_REPLAYABLE_MESSENGER.xDomainSender() == expectedL1Sender, whereexpectedL1Senderis the authorized L1 address.
Circumventing Core sequencer censorship for EVM-controlled Core accounts
An EOA controlling an EVM-controlled Core account can use an L1-to-EVM priority request to submit a Core operation without relying on ordinary transaction submission to the sequencer. Call ZkLighter.addEvmPriorityRequest(...) directly from that EOA on L1, with to set to EvmToCoreEntrypoint and data encoding a supported operation such as cancelAllOrders(accountIndex). A direct EOA call preserves the controller's address as msg.sender on EVM.
The sequencer is required to process the EVM priority request. Successful execution on EVM submits the write for Core to fulfill asynchronously. Both the priority request and the resulting Core write are covered by the protocol's escape-hatch conditions, so prolonged censorship at either stage allows anyone to activate the escape hatch.
For a contract-controlled Core account, route the priority request through the controlling EVM contract so that it submits the Core write.
EVM to L1
Initiating an L1 Call
EVM-originated L1 Calls pass through the EvmToL1Messenger predeploy. It supports arbitrary calls and token-info reports.
Arbitrary calls
To initiate an arbitrary L1 Call, call initiateWithdrawal(...) with the target address and calldata.
function initiateWithdrawal(
address _target,
bytes memory _data
) public payable;Parameters:
_targetis the address of the L1 contract to call._datais the calldata for the L1 call.
Implicit inputs:
msg.sender— Exposed to L1 (transiently) as the initiator of the withdrawal.msg.value— The amount of ETH to withdraw from Lighter EVM. If nonzero, the ETH is burned on the EVM side via aselfdestruct-based burner contract.
A successful call to initiateWithdrawal emits the following event:
enum WithdrawalKind { User, SendTokenInfo }
event WithdrawalInitiated(
address indexed sender,
address indexed to,
uint256 value,
uint256 nonce,
WithdrawalKind kind,
bytes data
);| Field | Description |
|---|---|
sender | The initiator of the withdrawal on Lighter EVM (msg.sender). |
to | The _target passed to initiateWithdrawal. |
value | The msg.value sent with the call. |
nonce | A monotonically incrementing counter tracking the total number of EVM withdrawals, maintained by the EvmToL1Messenger. |
kind | User for calls initiated through initiateWithdrawal(...). Token-info reports use SendTokenInfo. |
data | The _data passed to initiateWithdrawal. |
Token-info reports
Anyone can call EvmInteropManager.sendTokenInfo(...) to request a token-info report for an EVM token.
function sendTokenInfo(address evmToken) external;The resulting L1 Call targets L1InteropManager and carries the token's ERC-20 metadata and a protocol-supplied admin-slot value. The underlying EvmToL1Messenger.sendTokenInfo(...) method accepts calls only from EvmInteropManager.
See Token linking for the requirements and full workflow.
Executing the call on L1
As with Core-originated L1 Calls, EVM-originated L1 Calls become available for execution on L1 once their batch has been executed.
However, unlike Core-originated calls, EVM-originated L1 Calls are committed to on L1 through a Merkle root. Executing one therefore requires a Merkle proof of its inclusion in the batch, as outlined below.
Obtaining the proof
Lighter EVM nodes provide the proof and all execution arguments through lighter_getWithdrawalProof. Call this JSON-RPC method on the Lighter EVM RPC endpoint:
lighter_getWithdrawalProof(txHash, index)
Parameters:
| Parameter | Description |
|---|---|
txHash | Hash of the Lighter EVM transaction that emitted the WithdrawalInitiated event. |
index | Zero-based position of the WithdrawalInitiated event among those emitted by that transaction. 0 unless the transaction initiated several withdrawals. |
For example, using curl:
curl -s "$LIGHTER_EVM_RPC_URL" \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"lighter_getWithdrawalProof","params":["0x<l2 tx hash>", 0]}'The response contains the Merkle proof and all arguments of the L1 claim:
{
"batchNumber": 1287,
"proof": [
"0x9d2f…",
"0x41c0…"
],
"isLeft": "0x2",
"l1CallHash": "0x7b3e…",
"root": "0xe5a1…",
"l2Sender": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"to": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"value": "0x2386f26fc10000",
"msgNonce": "0x1c",
"data": "0x"
}Response fields:
| Field | Description |
|---|---|
batchNumber | The finalized batch that contains the withdrawal. First argument of executeEvmL1Call. |
proof | The Merkle proof. Empty when the batch contains a single withdrawal. |
isLeft | Position of the withdrawal in the batch's Merkle tree, as a hex quantity. Pass it as uint256. |
l1CallHash | Hash of the withdrawal message. |
root | The batch's Merkle root. |
l2Sender, to, value, msgNonce, data | The committed L1 Call fields used for execution. |
Until the withdrawal has been finalized on L1, the method returns an error. Retry after a few seconds.
Submitting the claim
Call executeEvmL1Call on the ZkLighter bridge contract on L1:
function executeEvmL1Call(
uint64 batchNumber,
bytes32[] calldata proof,
uint256 isLeft,
address _l2Sender,
address to,
uint256 value,
uint256 msgNonce,
bytes calldata data
) external;| Parameter | Source | Description |
|---|---|---|
batchNumber | proof | The finalized batch that contains the withdrawal. |
proof | proof | The Merkle proof. |
isLeft | proof | Position of the withdrawal in the batch's Merkle tree. |
_l2Sender | proof l2Sender | The call's authenticated EVM sender. Surfaced via l2Sender() during execution. |
to | proof to | The target of the L1 call. |
value | proof value | The amount of ETH sent with the call. |
msgNonce | proof msgNonce | The nonce of the withdrawal, as tracked by the EvmToL1Messenger. |
data | proof data | The calldata for the L1 call. |
All eight arguments are contained in the response of lighter_getWithdrawalProof.
Anyone can submit the claim; it is not submitted automatically. The ETH and calldata are delivered to to, and the submitter pays the L1 gas.
During execution, the target contract must authenticate the calling bridge before reading the original EVM sender through l2Sender().
function l2Sender() external view returns (address sender);The _l2Sender is stored transiently and is only available for the duration of the call.
If the downstream call reverts, the entire executeEvmL1Call transaction reverts and the withdrawal remains unclaimed, so it can be retried with the same proof. Each withdrawal can be claimed only once. Simulating the call with eth_call before sending it surfaces the revert reason and confirms that the withdrawal is claimable.
Updated about 7 hours ago
