EVM and Core

EVM and Core communicate through stateful write streams, one in each direction. These streams are settled on L1 together with Core and EVM state, allowing the sequencer to fulfill cross-domain write requests without waiting for L1 finality.

Rather than exposing arbitrary cross-domain messaging, the protocol defines a fixed set of operations that users can request on each domain.

Writes are asynchronous and processed first-in, first-out within each direction. The sequencer aims to fulfill requests as soon as possible, but the protocol does not guarantee a fixed delay between submission on one domain and fulfillment on the other.

Pending writes have the same censorship-resistance protection as L1 priority requests. If a write remains unfulfilled past its 14-day expiration period, the escape hatch can be activated on L1.

EVM to Core

EVM accounts can request actions on Core, such as placing orders or transferring tokens. Each request is submitted on EVM and processed asynchronously by Core.

Two EVM predeploys provide access to these writes.

EVM-to-Core write flow with separate Lighter EVM and Lighter Core domains, connected by a write stream across an asynchronous boundary.

Every write ultimately passes through EvmToCoreMessenger, which emits the request as raw bytes for Core to consume. However, its submission method accepts calls only from EvmToCoreEntrypoint, so users cannot bypass the supported operations to send arbitrary messages to Core.

Submitting a write to Core

Submit a write to Core through one of the two predeploys introduced above. Every submitted write is assigned a uint64 reqId that identifies the request on Core. The write methods return this ID.

If the EVM call reverts, no request is submitted. A successful EVM transaction confirms only that the request was submitted, not that Core executed it.

The Core sequencer validates each request against the state immediately before execution. This state may differ from what an earlier EVM read returned.

Core fulfillment can fail for various reasons, documented under each supported write. Since submission and fulfillment are not atomic, a failure on Core does not revert the initiating EVM transaction.

A failed request is still consumed and cannot be replayed. Trying again requires submitting a new request with a new reqId.

Multiple writes submitted in one EVM transaction are processed independently on Core, not atomically. One can succeed while another fails.

Keep the reqId to track the request's status and read its outcome from Core, as described in Reading the result of a write.

Reading the result of a write

Core records a status and a response for every request it processes. To retrieve them, call READ_CORE with the transaction-status selector (0x0000) and the request's reqId:

(bool success, bytes memory result) = READ_CORE.staticcall(
    abi.encodePacked(uint16(0x0000), reqId)
);
(uint8 status, bytes memory data) = abi.decode(result, (uint8, bytes));

Interpret the returned status and data using the following table.

statusMeaningdata
0Core has not processed the request yet.Empty.
1Core executed the write successfully.The write's return value, or empty if it has none.
2Core executed the write, but it failed with an error.abi.encode(uint32(errorCode)).

Each operation's Core response section describes its success data (status == 1) and possible error codes (status == 2).

When the result is visible. The result is never available in the EVM transaction that submitted the write. It appears in a later EVM block, once Core processes the request. Check from a later transaction, or poll off-chain with eth_call, until status is nonzero.

Pending versus unknown. Status 0 is also returned for request IDs that have never been issued. EvmToCoreMessenger.reqId() returns the next request ID to be assigned. A request is pending if its status is 0 and its reqId is less than this counter.

Example: submit, then read the result

The following contract submits a cancel-all request and exposes its outcome. The contract is the sender of the write, so it must control an EVM-controlled Core master account, and accountIndex must identify that master account or one of its subaccounts.

🚧

Warning

Core treats the immediate EVM caller as the authority for a write. For a contract-controlled Core account, this is the contract that calls EvmToCoreEntrypoint. Such contracts should enforce any desired access control over who may trigger Core writes through them.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IEvmToCoreEntrypoint {
    function cancelAllOrders(uint48 accountIndex) external returns (uint64 reqId);
}

contract CoreWriter {
    address constant READ_CORE = 0x6600000000000000000000000000000000000000;
    IEvmToCoreEntrypoint constant ENTRYPOINT =
        IEvmToCoreEntrypoint(0x6600000000000000000000000000000000000009);

    address public immutable owner;

    constructor() {
        owner = msg.sender;
    }

    // Step 1: submit the request. Core executes it in a later Core block.
    function cancelAll(uint48 accountIndex) external returns (uint64 reqId) {
        require(msg.sender == owner, "not owner");
        reqId = ENTRYPOINT.cancelAllOrders(accountIndex);
    }

    // Step 2: read the outcome from a later EVM block or with eth_call.
    function cancelAllResult(uint64 reqId)
        external
        view
        returns (uint8 status, uint64 ordersToCancel, uint32 errorCode)
    {
        (bool success, bytes memory result) = READ_CORE.staticcall(
            abi.encodePacked(uint16(0x0000), reqId)
        );
        require(success, "Core status read failed");

        bytes memory data;
        (status, data) = abi.decode(result, (uint8, bytes));
        if (status == 1) {
            ordersToCancel = abi.decode(data, (uint64));
        } else if (status == 2) {
            errorCode = abi.decode(data, (uint32));
        }
    }
}

Core transaction hash

Core APIs identify a fulfilled write by its Core transaction hash. To derive it, decode the WriteToCore(address indexed sender, uint64 indexed reqId, bytes writeRequest) event emitted by EvmToCoreMessenger in the EVM transaction receipt. Use the event's sender, reqId, and full writeRequest, including its transaction-type byte.

The following helper uses the network's Core chain ID, not its EVM chain ID. It hashes the packed fields, appends eight zero bytes, and returns the 80-character hexadecimal string expected by Core APIs, without a 0x prefix.

import { encodePacked, keccak256, padHex, type Address, type Hex } from 'viem';

function coreTxHash(
  coreChainId: number,
  sender: Address,
  reqId: bigint,
  writeRequest: Hex,
): string {
  const digest = keccak256(encodePacked(
    ['string', 'uint32', 'uint64', 'address', 'bytes'],
    ['zkl/toCoreTx/v1', coreChainId, reqId, sender, writeRequest],
  ));
  return padHex(digest, { size: 40, dir: 'right' }).slice(2);
}

If an EVM transaction submits multiple writes, select the event for the intended reqId. Each write has its own Core transaction hash. Use this hash when retrieving a Core-originated L1 Call.

Supported writes

The following methods belong to EvmToCoreEntrypoint.

FunctionDescription
changePubKeyUpdate or revoke a Core API key.
createOrderPlace an order on a Core market.
cancelOrderCancel an order by its Core or client identifier.
cancelAllOrdersCancel all open orders for a Core account.
registerSendToCoreRequest a deposit credit on Core. Callable only by EvmInteropManager.
finalizeEvmLinkNotify Core of an EVM-native asset's canonical token. Callable only by EvmInteropManager.
registerSendToEvmRequest a Core balance debit and delivery on EVM. Callable only by EvmInteropManager.
burnSharesBurn public-pool shares held by a Core account.
withdrawToL1AddressRequest a withdrawal from a Core balance to an L1 recipient.

Each Core response describes the status and data returned when reading the result of a write.

Change public key

Call EvmToCoreEntrypoint.changePubKey to update or revoke a Core API key. The apiKeyIndex identifies the key to update, and pubKey supplies its new public key.

The sender must control an EVM-controlled Core master account, and accountIndex must identify that master account, one of its subaccounts, or a public pool it controls. Staking pool accounts are not supported.

Call

function changePubKey(
    uint48 accountIndex,
    uint8 apiKeyIndex,
    bytes calldata pubKey
) external returns (uint64 reqId);

Parameters

TypeParameterDescription
uint48accountIndexCore account whose API key is updated.
uint8apiKeyIndexIndex of the API key to update, from 0 to 254.
bytespubKeyNew public key in Core's required 40-byte encoding, or 40 zero bytes to revoke the API key.

To generate a key pair using Lighter's SDKs, see How to create API keys programmatically.

An invalid apiKeyIndex or pubKey encoding causes the EVM call to revert before a write is submitted to Core.

Core response

On status == 1, data is empty.

On status == 2, data is:

abi.encode(uint32(errorCode))
Error codeMeaning
6401Sender has no pre-existing Core master account.
6402accountIndex is not controlled by sender.
6403accountIndex does not exist.
6404accountIndex is a staking pool.
9999Write was pending at Desert Mode activation.

Create order

Call EvmToCoreEntrypoint.createOrder to place an order for the selected Core account. The sender must control an EVM-controlled Core master account, and accountIndex must identify that master account or one of its subaccounts.

Call

function createOrder(
    uint48 accountIndex,
    uint16 marketIndex,
    uint48 clientOrderIndex,
    uint8 orderType,
    uint8 timeInForce,
    uint64 orderExpiry,
    uint48 amount,
    uint32 price,
    bool isAsk,
    bool reduceOnly,
    uint32 triggerPrice
) external returns (uint64 reqId);

Parameters

TypeParameterDescription
uint48accountIndexCore account placing the order.
uint16marketIndexMarket identifier. Perpetuals: 0 <= marketIndex < 255; spot: 2048 <= marketIndex < 4095.
uint48clientOrderIndexOptional account-scoped order identifier; 0 omits it.
uint8orderType0: limit; 1: market; 2: stop-loss market; 3: stop-loss limit; 4: take-profit market; 5: take-profit limit; 6: time-weighted average price (TWAP). Stop-loss and take-profit orders are perpetuals-only.
uint8timeInForce0: immediate-or-cancel; 1: good-til-time; 2: post-only. Market, stop-loss market and take-profit market orders must use 0; TWAP orders must use 1.
uint64orderExpiryExpiration time as a Unix timestamp in milliseconds. Market orders and immediate-or-cancel limit orders must use 0. All other orders need an expiry between 5 minutes and 30 days after the Core block time.
uint48amountOrder size multiplied by 10^supported_size_decimals. Immediate-or-cancel and TWAP orders are exempt from the market's minimum size and quote value.
uint32pricePrice in the quote asset multiplied by 10^supported_price_decimals.
boolisAsktrue for an ask (sell); false for a bid (buy).
boolreduceOnlyWhether the order may only reduce position size.
uint32triggerPriceTrigger price for a stop-loss or take-profit order, using the same scale as price. Must be 0 for every other order type.

Read both decimal counts from the selected market in Core's GET /api/v1/orderBookDetails response, which lists spot markets under spot_order_book_details. They are market-specific and may differ from the asset's transfer decimals. The scaled values must be exact integers.

Core response

On status == 1, data is:

abi.encode(orderIndex)
TypeFieldDescription
uint64orderIndexCore-assigned order identifier.

On status == 2, data is:

abi.encode(uint32(errorCode))
Error codeMeaning
6501clientOrderIndex already exists.
6502Order amount or quote value is outside market bounds.
6503Reduce-only order would increase exposure.
6504Order expiry is invalid.
6505Trigger configuration is invalid for orderType.
6506Sender has no pre-existing Core master account.
6507accountIndex is not controlled by sender.
6508marketIndex is invalid or inactive.
6509Order configuration is unsupported by the selected market.
6512Order-capacity limit reached.
6513Account is not eligible to place the order on the selected market.
6514accountIndex does not exist.
9999Write was pending at Desert Mode activation.

Core does not fail an order for insufficient margin or balance. Like any Core order, an order that passes these checks returns status == 1 and then goes through matching, which can cancel it, for example for insufficient margin. Core's API reports the order's status by orderIndex.

Cancel order

Call EvmToCoreEntrypoint.cancelOrder to cancel an active order belonging to accountIndex. The sender must control an EVM-controlled Core master account, and accountIndex must identify that master account or one of its subaccounts.

Call

function cancelOrder(uint48 accountIndex, uint64 orderIndex) external returns (uint64 reqId);

Parameters

TypeParameterDescription
uint48accountIndexCore account whose order is cancelled.
uint64orderIndexCore-assigned orderIndex or client-supplied clientOrderIndex. Values below 2^48 are read as a clientOrderIndex; Core-assigned indices start at 2^48.

Core response

On status == 1, data is empty.

On status == 2, data is:

abi.encode(uint32(errorCode))
Error codeMeaning
6601orderIndex does not resolve to an active order for accountIndex.
6602Sender has no pre-existing Core master account.
6603accountIndex is not controlled by sender.
6604The selected order's market does not permit cancellation.
6605accountIndex does not exist.
9999Write was pending at Desert Mode activation.

Cancel all orders

Call EvmToCoreEntrypoint.cancelAllOrders to select all open orders on accountIndex for cancellation. The sender must control an EVM-controlled Core master account, and accountIndex must identify that master account or one of its subaccounts.

Call

function cancelAllOrders(uint48 accountIndex) external returns (uint64 reqId);

Parameters

TypeParameterDescription
uint48accountIndexCore account whose orders are cancelled.

Core response

On status == 1, data is:

abi.encode(ordersToCancel)
TypeFieldDescription
uint64ordersToCancelNumber of open orders selected for cancellation when Core accepts the request.

The request succeeds even if there are no open orders, returning ordersToCancel == 0 with status == 1.

On status == 2, data is:

abi.encode(uint32(errorCode))
Error codeMeaning
6701Sender has no pre-existing Core master account.
6702accountIndex is not controlled by sender.
6704accountIndex does not exist.
9999Write was pending at Desert Mode activation.

Send to Core

EvmToCoreEntrypoint.registerSendToCore(...) submits a request to credit a Core master account. Only EvmInteropManager can call it, after handling the tokens on EVM. See EVM-to-Core transfers for the user-facing flow.

Call

function registerSendToCore(
    address sender,
    uint32 assetIndex,
    address to,
    bool isL1Account,
    RouteType routeType,
    uint64 baseAmount
) external returns (uint64 reqId);

Parameters

TypeParameterDescription
addresssenderOriginal EVM caller, recorded as the message sender.
uint32assetIndexAsset identifier.
addresstoControlling address of the recipient Core master account.
boolisL1Accounttrue for an L1-controlled Core account; false for an EVM-controlled Core account.
RouteTyperouteTypeDestination balance: Perps = 0 or Spot = 1.
uint64baseAmountRequested credit in Core base units.

Core response

On status == 1, data is:

abi.encode(acceptedAmount)
TypeFieldDescription
uint64acceptedAmountAmount credited on Core, in base units: 0 <= acceptedAmount <= baseAmount.

A successful status (status == 1) does not guarantee that the full deposit was credited. Check acceptedAmount, which may be zero. Any unaccepted amount is refunded separately; see Send to Core Refunds.

This write never returns status == 2.

Finalize EVM link

EvmToCoreEntrypoint.finalizeEvmLink(...) notifies Core of the canonical EVM token for an EVM-native asset. Only EvmInteropManager can call it, and the request records that contract as its sender. See Linking the canonical token for the full flow.

Call

function finalizeEvmLink(uint32 assetIndex, address evmToken) external;

Parameters

TypeParameterDescription
uint32assetIndexIndex of the EVM-native Core asset.
addressevmTokenCanonical EVM token address to record on Core.

Core response

On status == 1, data is empty. Core records the token address and creates a confirmation message to L1. A successful Core status does not mean that the confirmation has been executed on L1.

On status == 2, data is:

abi.encode(uint32(errorCode))
Error codeMeaning
9999Write was pending at Desert Mode activation.

EVM-initiated send to EVM

EvmToCoreEntrypoint.registerSendToEvm(...) submits a request to debit a Core account and schedule delivery to an EVM recipient. Only EvmInteropManager can call it. Core checks account authorization against the supplied sender, not the calling contract. See EVM-initiated transfers to EVM for the user-facing flow.

Call

function registerSendToEvm(
    address sender,
    uint48 accountIndex,
    uint32 assetIndex,
    address evmRecipient,
    RouteType routeType,
    uint64 baseAmount
) external returns (uint64 reqId);

Parameters

TypeParameterDescription
addresssenderOriginal EVM caller, recorded as the message sender and used for Core authorization.
uint48accountIndexCore account to debit.
uint32assetIndexAsset identifier.
addressevmRecipientRecipient address on EVM.
RouteTyperouteTypeSource balance: Perps = 0 or Spot = 1.
uint64baseAmountAmount to transfer in base units.

Core response

On status == 1, data is empty.

When Core accepts the request, it debits the account's balance for the selected asset and route, then creates a Core-to-EVM write for delivery to evmRecipient. A successful Core status does not confirm delivery on EVM.

On status == 2, data is:

abi.encode(uint32(errorCode))
Error codeMeaning
7001Sender has no pre-existing Core master account.
7002accountIndex is not controlled by sender.
7003Account has insufficient available balance.
7004The selected account's perps route is disabled.
7005baseAmount exceeds Core's supported range.
7006accountIndex does not exist.
7007assetIndex is not linked on EVM.
9999Write was pending at Desert Mode activation.

Burn shares

Call EvmToCoreEntrypoint.burnShares to burn public-pool shares held by accountIndex. The sender must control an EVM-controlled Core master account, and accountIndex must identify that master account or one of its subaccounts.

Call

function burnShares(
    uint48 accountIndex,
    uint48 publicPoolIndex,
    uint64 shareAmount
) external returns (uint64 reqId);

Parameters

TypeParameterDescription
uint48accountIndexCore account whose shares are burned.
uint48publicPoolIndexPublic-pool account whose shares are burned.
uint64shareAmountAmount of shares to burn.

Core response

On status == 1, data is empty.

On status == 2, data is:

abi.encode(uint32(errorCode))
Error codeMeaning
7101Sender has no pre-existing Core master account.
7102accountIndex is not controlled by sender.
7103publicPoolIndex is invalid.
7105Account has insufficient available shares.
7106Pool is not currently burnable.
7107Burn output is invalid for the pool state.
7108accountIndex does not exist.
9999Write was pending at Desert Mode activation.

EVM-initiated withdrawal to L1

Call EvmToCoreEntrypoint.withdrawToL1Address to withdraw from a Core account to L1. The sender must control an EVM-controlled Core master account, and accountIndex must identify that master account or one of its subaccounts. The asset must be L1-native or an EVM-native asset with a finalized L1 wrapper link.

Call

function withdrawToL1Address(
    uint48 accountIndex,
    uint32 assetIndex,
    address l1Recipient,
    RouteType routeType,
    uint64 baseAmount
) external returns (uint64 reqId);

Parameters

TypeParameterDescription
uint48accountIndexCore account to debit.
uint32assetIndexAsset identifier; must have an active L1 representation.
addressl1RecipientRecipient address on L1; must not be address(0).
RouteTyperouteTypeSource balance: Perps = 0 or Spot = 1.
uint64baseAmountAmount to withdraw in base units; must be nonzero.

Core response

On status == 1, data is empty.

When Core accepts the request, it debits the account's balance for the selected asset and route, then creates the withdrawal to l1Recipient. A successful Core status does not confirm execution on L1; execution occurs in a separate step. Core charges no withdrawal fee for this request.

On status == 2, data is:

abi.encode(uint32(errorCode))
Error codeMeaning
7601Sender has no pre-existing Core master account.
7602accountIndex is not controlled by sender.
7603Asset has no active L1 representation.
7604baseAmount is below the asset's minimum withdrawal.
7605Account has insufficient available balance.
7606The selected account's perps route is disabled.
7608accountIndex does not exist.
9999Write was pending at Desert Mode activation.

Core to EVM

Currently, the only supported Core to EVM message is token transfers. See Core-to-EVM transfers for more details.


Did this page help you?